mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195807643f | ||
|
|
21256e6d55 | ||
|
|
705fa875d3 | ||
|
|
498384357e | ||
|
|
53938e4cf8 | ||
|
|
e18ffcd48e | ||
|
|
df12f86da9 | ||
|
|
3dba7f2bf7 | ||
|
|
e7efe14336 | ||
|
|
78f2103bcb | ||
|
|
565b0ec425 | ||
|
|
253d72bb4d | ||
|
|
35522e9b28 | ||
|
|
4aa9522825 | ||
|
|
a92b0b0ad5 | ||
|
|
66cdccb0e7 | ||
|
|
2eefa44f8f | ||
|
|
55302dd830 | ||
|
|
924eb87157 | ||
|
|
f546bf3352 | ||
|
|
97553334d4 | ||
|
|
31d12b9888 | ||
|
|
dc2658f63b | ||
|
|
b8b9de78fc | ||
|
|
8ff599b1f2 | ||
|
|
e6f02af973 | ||
|
|
a163b5a76d | ||
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 | ||
|
|
8373ae353c | ||
|
|
994794dbad | ||
|
|
d103da3bc5 | ||
|
|
ee3c62683b | ||
|
|
8ac9d5f101 | ||
|
|
1eaa799b9f | ||
|
|
bb0f2dfb21 | ||
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: test-egress-guard
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/build-tools/resources/egress-guard/**
|
||||
- packages/build-tools/src/steps/utils/localEgressGuard.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/localEgressGuard*.ts
|
||||
- packages/build-tools/src/steps/functions/startIosSimulator.ts
|
||||
- packages/worker/.eas/workflows/test-egress-guard.yml
|
||||
- .github/workflows/test-egress-guard.yml
|
||||
|
||||
jobs:
|
||||
test-egress-guard:
|
||||
# The simulator tests need macOS with Xcode, which the EAS macOS workers have.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
- name: Run the test-egress-guard EAS workflow on macOS
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
eas workflow:run test-egress-guard.yml \
|
||||
--ref ${{ github.event.pull_request.head.sha || github.sha }} \
|
||||
--non-interactive \
|
||||
--wait
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22', '24']
|
||||
node: ['20.18.3', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
- node: '20.18.3'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
|
||||
@@ -6,19 +6,109 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Require Node `^20.18.3 || >=22.0.0`. ([#4279](https://github.com/expo/eas-cli/pull/4279) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Inject a guard into every iOS Simulator process during `--egress local` sessions that refuses connections which bypass the system proxy and reports them, with the calling frameworks, in the session log. ([#4393](https://github.com/expo/eas-cli/pull/4393) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "23.2.0",
|
||||
"PRODUCTION": "23.2.0"
|
||||
"STAGING": "24.4.1",
|
||||
"PRODUCTION": "24.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "23.2.0",
|
||||
"version": "24.4.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -8,3 +8,5 @@ bin/record-sim
|
||||
|
||||
src/graphql-env.d.ts
|
||||
schema.graphql
|
||||
bin/egress-guard.dylib
|
||||
bin/egress-guard-check
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "23.2.0",
|
||||
"version": "24.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,6 +24,8 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
@@ -39,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "23.0.0",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/template-file": "23.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/steps": "24.3.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -75,6 +77,7 @@
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -92,6 +95,7 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# egress-guard
|
||||
|
||||
A small dylib injected into every process the iOS Simulator launches during a
|
||||
`--egress local` session. It interposes the socket calls that open outbound
|
||||
traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
|
||||
variants of the last three that libsystem exports alongside them) and refuses
|
||||
any destination that is not loopback. A sockaddr whose family was left
|
||||
`AF_UNSPEC` is classified as the family its length implies, which is how the
|
||||
kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
|
||||
`connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
|
||||
forwards live on loopback, so everything that honors the proxy keeps working
|
||||
and everything that bypasses it fails with `ECONNREFUSED` in the process that
|
||||
tried, with the calling frameworks recorded.
|
||||
|
||||
Why interposing: simulator processes share the worker's uid, so a packet
|
||||
filter cannot tell them apart from the worker's own traffic. dyld interposing
|
||||
from an inserted library reaches calls made inside Apple's own frameworks
|
||||
(CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
|
||||
inside the simulator makes launchd hand the library to every process it
|
||||
spawns. The simulator does not enforce library validation, which is what makes
|
||||
this possible there and nowhere else.
|
||||
|
||||
Configuration comes from the environment the simulator's launchd provides:
|
||||
|
||||
- `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
|
||||
- `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
|
||||
or `log` (observe only).
|
||||
|
||||
Each event is one tab-separated line:
|
||||
|
||||
```
|
||||
eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
|
||||
```
|
||||
|
||||
A process lists each `(function, peer)` once, up to 128 of them. When that
|
||||
table fills it writes one more line with function `overflow` and the limit as
|
||||
the peer; further distinct destinations are still refused but not listed.
|
||||
|
||||
Layout:
|
||||
|
||||
- `policy.c` / `policy.h`: classification, mode, formatting, per-process
|
||||
dedupe. Pure C, host-testable.
|
||||
- `guard.c`: the interposers and the constructor that reads the environment.
|
||||
- `check.c`: `egress-guard-check`, run inside the simulator right after the
|
||||
guard is installed. Exits non-zero unless the library is loaded in a fresh
|
||||
process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
|
||||
`connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
|
||||
- `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
|
||||
- `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
|
||||
interposers themselves, `tests/run-guard-tests.sh`. The first compiles
|
||||
`guard.c` in and forces the lock schedules (a signal handler making a socket
|
||||
call inside the critical section, a fork while another thread holds the lock,
|
||||
contention); the second inserts the built library like the simulator does and
|
||||
checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
|
||||
UDP disconnect. Nothing in them opens a socket to a non-loopback address.
|
||||
- `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
|
||||
a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
|
||||
- `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
|
||||
|
||||
Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
|
||||
variable of its own environment to the process it starts, and for `simctl
|
||||
boot` that process is the simulator's launchd, so the worker boots with the
|
||||
guard and proxy variables in that form and every process of the boot inherits
|
||||
them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
|
||||
device that was already booted, but by then the boot's own processes have
|
||||
started without it. It also cannot change a variable the boot already carried:
|
||||
a device booted with one guard configuration keeps it until it is shut down,
|
||||
which is why the worker boots devices itself and every configuration in the
|
||||
end-to-end test gets its own boot. The self-check runs once boot completes and is followed
|
||||
by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
refusals still happen and only the reporting is lost. Recording is best-effort
|
||||
in the process too: the event log is opened per event rather than held open
|
||||
(a process that closes descriptors it does not own, as `launchd_sim` does,
|
||||
would otherwise make the guard write into whatever reused the number), and a
|
||||
dedupe slot that cannot be taken within a bounded number of attempts (a signal
|
||||
handler re-entering the guard, a fork child inheriting a held lock) skips the
|
||||
event rather than blocking or aborting the process.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
|
||||
# packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Output directory; defaults to packages/build-tools/bin.
|
||||
bin_dir="${1:-../../bin}"
|
||||
mkdir -p "$bin_dir"
|
||||
out=$(mktemp -d)
|
||||
sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-dynamiclib -install_name @rpath/egress-guard.dylib \
|
||||
-o "$out/egress-guard-$arch.dylib" policy.c guard.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
|
||||
lipo -info "$bin_dir/egress-guard.dylib"
|
||||
|
||||
# The self-check the worker runs inside the simulator after installing the guard.
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-o "$out/egress-guard-check-$arch" check.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
|
||||
chmod +x "$bin_dir/egress-guard-check"
|
||||
lipo -info "$bin_dir/egress-guard-check"
|
||||
@@ -0,0 +1,112 @@
|
||||
// Self-check run inside a simulator right after the guard is installed:
|
||||
// proves the guard library is loaded in a freshly spawned process and that it
|
||||
// behaves as the requested mode says, on every connect entry point the guard
|
||||
// covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
|
||||
// loaded but did not behave; 1 on usage errors.
|
||||
//
|
||||
// egress-guard-check --mode block|log
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
|
||||
// connect reports EINPROGRESS and nothing ever answers.
|
||||
#define PROBE_ADDRESS "192.0.2.1"
|
||||
#define PROBE_PORT 9
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
|
||||
static int guard_loaded(void) {
|
||||
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
|
||||
const char *name = _dyld_get_image_name(i);
|
||||
if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One non-blocking connect to the probe address through `entry`, with the
|
||||
// sockaddr family the caller wants. Returns the connect result; errno is
|
||||
// stored in *err.
|
||||
static int probe(connect_fn entry, sa_family_t family, int *err) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
*err = errno;
|
||||
return -2;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
struct sockaddr_in to;
|
||||
memset(&to, 0, sizeof to);
|
||||
to.sin_family = family;
|
||||
to.sin_len = sizeof to;
|
||||
to.sin_port = htons(PROBE_PORT);
|
||||
inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
|
||||
int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
|
||||
*err = errno;
|
||||
close(fd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *mode = "block";
|
||||
if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
|
||||
mode = argv[2];
|
||||
} else if (argc != 1) {
|
||||
fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
|
||||
return 1;
|
||||
}
|
||||
if (!guard_loaded()) {
|
||||
printf("egress-guard-check: the guard library is not loaded in this process\n");
|
||||
return 2;
|
||||
}
|
||||
connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
if (connect_nocancel == NULL) {
|
||||
printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// The plain call, the non-cancelable variant, and a sockaddr whose family
|
||||
// was left AF_UNSPEC, which the kernel connects as IPv4.
|
||||
const struct {
|
||||
const char *name;
|
||||
connect_fn entry;
|
||||
sa_family_t family;
|
||||
} probes[] = {
|
||||
{"connect", connect, AF_INET},
|
||||
{"connect$NOCANCEL", connect_nocancel, AF_INET},
|
||||
{"connect with AF_UNSPEC", connect, AF_UNSPEC},
|
||||
};
|
||||
int block = strcmp(mode, "block") == 0;
|
||||
for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
|
||||
int err = 0;
|
||||
int rc = probe(probes[i].entry, probes[i].family, &err);
|
||||
if (rc == -2) {
|
||||
printf("egress-guard-check: socket() failed: %s\n", strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (block && !(rc == -1 && err == ECONNREFUSED)) {
|
||||
printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
|
||||
probes[i].name, rc, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
|
||||
printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
|
||||
probes[i].name, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
if (block) {
|
||||
printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
|
||||
} else {
|
||||
printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// The local egress guard: interposes the calls that open outbound traffic in
|
||||
// every simulator process it is inserted into, refuses non-loopback
|
||||
// destinations in block mode, and records one event per destination per
|
||||
// process. See README.md and policy.h.
|
||||
#include "policy.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <execinfo.h>
|
||||
#include <fcntl.h>
|
||||
#include <os/lock.h>
|
||||
#include <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
|
||||
#define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
|
||||
#define EG_MAX_CALLERS 6
|
||||
#define EG_CALLER_LENGTH 64
|
||||
#define EG_LOCK_ATTEMPTS 64
|
||||
|
||||
static int eg_initialized = 0;
|
||||
static void *eg_own_image = NULL;
|
||||
static char eg_log_path[1024];
|
||||
static eg_mode_t eg_mode = EG_MODE_BLOCK;
|
||||
static eg_seen_t eg_seen;
|
||||
static int eg_overflow_reported = 0;
|
||||
static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
|
||||
|
||||
static void eg_init(void) {
|
||||
if (eg_initialized) {
|
||||
return;
|
||||
}
|
||||
eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
|
||||
Dl_info self;
|
||||
if (dladdr((const void *)eg_init, &self)) {
|
||||
eg_own_image = self.dli_fbase;
|
||||
}
|
||||
const char *log_path = getenv(EG_LOG_ENV);
|
||||
// The path is kept rather than an open descriptor: a process that closes
|
||||
// descriptors it does not know about (launchd_sim does) would otherwise
|
||||
// leave the guard writing into whatever file reused the number.
|
||||
if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
|
||||
strcpy(eg_log_path, log_path);
|
||||
}
|
||||
eg_initialized = 1;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
|
||||
|
||||
// Image names of the frames above the guard's own, innermost first, with
|
||||
// consecutive repeats collapsed: "Network,CFNetwork,MyApp".
|
||||
__attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
|
||||
void *frames[EG_MAX_CALLERS + 6];
|
||||
int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
|
||||
int count = 0;
|
||||
for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
|
||||
Dl_info info;
|
||||
const char *image = "?";
|
||||
if (dladdr(frames[i], &info)) {
|
||||
if (info.dli_fbase == eg_own_image) {
|
||||
continue;
|
||||
}
|
||||
if (info.dli_fname != NULL) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
image = slash ? slash + 1 : info.dli_fname;
|
||||
}
|
||||
}
|
||||
if (count > 0 && strcmp(names[count - 1], image) == 0) {
|
||||
continue;
|
||||
}
|
||||
strncpy(names[count], image, EG_CALLER_LENGTH - 1);
|
||||
names[count][EG_CALLER_LENGTH - 1] = '\0';
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A lock held by this same thread (a signal handler making a socket call
|
||||
// while the interrupted call held it) or by a thread that did not survive
|
||||
// fork() never becomes available and os_unfair_lock_lock aborts the process
|
||||
// in both cases. Give up on recording the event instead; refusal does not
|
||||
// depend on it. Ordinary contention over the microsecond-long critical
|
||||
// section resolves within the first attempts.
|
||||
static int eg_lock_bounded(void) {
|
||||
for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
|
||||
if (os_unfair_lock_trylock(&eg_lock)) {
|
||||
return 1;
|
||||
}
|
||||
sched_yield();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One line in the event log. O_APPEND keeps whole lines intact across
|
||||
// processes writing concurrently; the log is opened per event, at most
|
||||
// EG_SEEN_CAPACITY + 1 times per process.
|
||||
__attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
|
||||
const char *peer, int with_callers) {
|
||||
if (eg_log_path[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
|
||||
const char *callers[EG_MAX_CALLERS];
|
||||
int caller_count = with_callers ? eg_collect_callers(names) : 0;
|
||||
for (int i = 0; i < caller_count; i++) {
|
||||
callers[i] = names[i];
|
||||
}
|
||||
char line[1024];
|
||||
int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
|
||||
callers, caller_count);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
(void)write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 1 when the call must be refused.
|
||||
__attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
eg_class_t cls = eg_classify(sa, len);
|
||||
if (cls != EG_REMOTE) {
|
||||
return 0;
|
||||
}
|
||||
eg_init();
|
||||
int deny = eg_should_deny(eg_mode, cls);
|
||||
|
||||
char peer[96];
|
||||
if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
|
||||
strncpy(peer, "?", sizeof peer);
|
||||
}
|
||||
char key[EG_SEEN_KEY_LENGTH];
|
||||
snprintf(key, sizeof key, "%s %s", function, peer);
|
||||
int fresh = 0;
|
||||
int overflowed = 0;
|
||||
if (eg_lock_bounded()) {
|
||||
fresh = eg_seen_insert(&eg_seen, key);
|
||||
if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
|
||||
eg_overflow_reported = 1;
|
||||
overflowed = 1;
|
||||
}
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
}
|
||||
|
||||
const char *action = deny ? "blocked" : "logged";
|
||||
if (fresh) {
|
||||
eg_write_event(function, action, peer, 1);
|
||||
} else if (overflowed) {
|
||||
// Once per process: the table is full, so further distinct destinations
|
||||
// are still refused but no longer listed.
|
||||
char limit[64];
|
||||
snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
|
||||
eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
|
||||
}
|
||||
return deny;
|
||||
}
|
||||
|
||||
// connect() with an AF_UNSPEC address on a datagram socket dissolves the
|
||||
// association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
|
||||
// to the kernel. On a stream socket the kernel connects to the address the
|
||||
// length implies, which eg_handle classifies.
|
||||
static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
|
||||
return 0;
|
||||
}
|
||||
int type = 0;
|
||||
socklen_t type_len = sizeof type;
|
||||
return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
|
||||
}
|
||||
|
||||
typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
|
||||
socklen_t);
|
||||
typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
// The non-cancelable variants libsystem_kernel exports next to the cancelable
|
||||
// ones; dyld interposing is per symbol, so each needs its own entry.
|
||||
extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
|
||||
"_connect$NOCANCEL");
|
||||
extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
|
||||
const struct sockaddr *, socklen_t) __asm__(
|
||||
"_sendto$NOCANCEL");
|
||||
extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
|
||||
"_sendmsg$NOCANCEL");
|
||||
|
||||
static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(connect, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
|
||||
unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
|
||||
size_t *len, sae_connid_t *connid) {
|
||||
if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
|
||||
!eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
|
||||
eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
|
||||
int flags, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa != NULL && eg_handle("sendto", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
|
||||
const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
|
||||
int flags) {
|
||||
if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
|
||||
eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(sendmsg, fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
|
||||
}
|
||||
|
||||
// dyld applies these to every image in the process, including the shared
|
||||
// cache, which is how calls made inside CFNetwork and Network.framework are
|
||||
// caught.
|
||||
__attribute__((used)) static const struct {
|
||||
const void *replacement;
|
||||
const void *original;
|
||||
} eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
|
||||
{(const void *)eg_connect, (const void *)connect},
|
||||
{(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
|
||||
{(const void *)eg_connectx, (const void *)connectx},
|
||||
{(const void *)eg_sendto, (const void *)sendto},
|
||||
{(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
|
||||
{(const void *)eg_sendmsg, (const void *)sendmsg},
|
||||
{(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
#include "policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int is_v4_local(uint32_t host_order) {
|
||||
return (host_order >> 24) == 127 || host_order == 0;
|
||||
}
|
||||
|
||||
// XNU accepts a sockaddr whose family is AF_UNSPEC as the family its length
|
||||
// implies: TCP connect() translates it on both socket families and IPv4
|
||||
// sendto()/sendmsg() deliver with it. Classify it the way the kernel uses it.
|
||||
static sa_family_t eg_family(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa->sa_family == AF_UNSPEC) {
|
||||
if (len == sizeof(struct sockaddr_in)) {
|
||||
return AF_INET;
|
||||
}
|
||||
if (len == sizeof(struct sockaddr_in6)) {
|
||||
return AF_INET6;
|
||||
}
|
||||
}
|
||||
return sa->sa_family;
|
||||
}
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET) {
|
||||
if (len < sizeof(struct sockaddr_in)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
return is_v4_local(ntohl(in->sin_addr.s_addr)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
if (family == AF_INET6) {
|
||||
if (len < sizeof(struct sockaddr_in6)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct in6_addr *a6 = &((const struct sockaddr_in6 *)sa)->sin6_addr;
|
||||
if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_UNSPECIFIED(a6)) {
|
||||
return EG_LOOPBACK;
|
||||
}
|
||||
if (IN6_IS_ADDR_V4MAPPED(a6)) {
|
||||
uint32_t v4;
|
||||
memcpy(&v4, &a6->s6_addr[12], sizeof v4);
|
||||
return is_v4_local(ntohl(v4)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
return EG_REMOTE;
|
||||
}
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
|
||||
eg_mode_t eg_parse_mode(const char *value) {
|
||||
if (value != NULL && strcmp(value, "log") == 0) {
|
||||
return EG_MODE_LOG;
|
||||
}
|
||||
return EG_MODE_BLOCK;
|
||||
}
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls) {
|
||||
return mode == EG_MODE_BLOCK && cls == EG_REMOTE;
|
||||
}
|
||||
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n) {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
int written;
|
||||
if (sa == NULL || len < 2) {
|
||||
return -1;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET && len >= sizeof(struct sockaddr_in)) {
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
if (inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "%s:%d", ip, ntohs(in->sin_port));
|
||||
} else if (family == AF_INET6 && len >= sizeof(struct sockaddr_in6)) {
|
||||
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa;
|
||||
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "[%s]:%d", ip, ntohs(in6->sin6_port));
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
return (written < 0 || (size_t)written >= n) ? -1 : 0;
|
||||
}
|
||||
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
for (int i = 0; i < seen->count; i++) {
|
||||
if (strncmp(seen->keys[i], key, EG_SEEN_KEY_LENGTH - 1) == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (seen->count >= EG_SEEN_CAPACITY) {
|
||||
seen->overflow++;
|
||||
return 0;
|
||||
}
|
||||
strncpy(seen->keys[seen->count], key, EG_SEEN_KEY_LENGTH - 1);
|
||||
seen->keys[seen->count][EG_SEEN_KEY_LENGTH - 1] = '\0';
|
||||
seen->count++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append `text` to out[*pos], replacing field separators; returns 0 when it fit.
|
||||
static int append_field(char *out, size_t n, size_t *pos, const char *text) {
|
||||
for (const char *c = text; *c; c++) {
|
||||
if (*pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[(*pos)++] = (*c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_raw(char *out, size_t n, size_t *pos, const char *text) {
|
||||
size_t len = strlen(text);
|
||||
if (*pos + len + 1 > n) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out + *pos, text, len);
|
||||
*pos += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count) {
|
||||
size_t pos = 0;
|
||||
char pid_text[16];
|
||||
snprintf(pid_text, sizeof pid_text, "%d", pid);
|
||||
if (append_raw(out, n, &pos, EG_EVENT_PREFIX "\t") != 0 ||
|
||||
append_field(out, n, &pos, progname ? progname : "?") != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_raw(out, n, &pos, pid_text) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, function) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, action) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, peer) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < caller_count && callers != NULL; i++) {
|
||||
if (i > 0 && append_raw(out, n, &pos, ",") != 0) {
|
||||
return -1;
|
||||
}
|
||||
// Commas separate callers, so they cannot appear inside one.
|
||||
for (const char *c = callers[i]; *c; c++) {
|
||||
char ch = (*c == ',' || *c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
if (pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[pos++] = ch;
|
||||
}
|
||||
}
|
||||
if (append_raw(out, n, &pos, "\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
out[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Policy and formatting for the local egress guard. Pure functions with no
|
||||
// interposing, so they compile and test on macOS as well as in the simulator.
|
||||
#ifndef EAS_EGRESS_GUARD_POLICY_H
|
||||
#define EAS_EGRESS_GUARD_POLICY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
// What a destination address is, as far as the guard cares.
|
||||
typedef enum {
|
||||
EG_PASSTHROUGH = 0, // not an internet address (unix sockets, NULL, short); never touched
|
||||
EG_LOOPBACK = 1, // 127.0.0.0/8, ::1, ::ffff:127.x, unspecified; the proxy and forwards live here
|
||||
EG_REMOTE = 2, // anything else, including link-local and multicast
|
||||
} eg_class_t;
|
||||
|
||||
typedef enum {
|
||||
EG_MODE_BLOCK = 0, // refuse EG_REMOTE with ECONNREFUSED
|
||||
EG_MODE_LOG = 1, // observe only
|
||||
} eg_mode_t;
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len);
|
||||
|
||||
// "block" or unset selects block; "log" selects log. Anything else is block:
|
||||
// an unknown mode must fail closed.
|
||||
eg_mode_t eg_parse_mode(const char *value);
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls);
|
||||
|
||||
// Writes "1.2.3.4:443" or "[2001:db8::1]:443". Returns 0 on success.
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n);
|
||||
|
||||
// Fixed-capacity set of strings, so a retrying client logs a destination once
|
||||
// per process instead of once per attempt.
|
||||
#define EG_SEEN_CAPACITY 128
|
||||
#define EG_SEEN_KEY_LENGTH 192
|
||||
typedef struct {
|
||||
char keys[EG_SEEN_CAPACITY][EG_SEEN_KEY_LENGTH];
|
||||
int count;
|
||||
int overflow; // insertions refused because the table was full
|
||||
} eg_seen_t;
|
||||
|
||||
// 1 when the key was not present and was inserted; 0 when already present or
|
||||
// when the table is full (counted in overflow).
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key);
|
||||
|
||||
#define EG_EVENT_PREFIX "eas-egress-guard"
|
||||
|
||||
// Function name of the one event a process writes when its seen table fills:
|
||||
// from then on distinct destinations are still refused but no longer listed.
|
||||
#define EG_OVERFLOW_FUNCTION "overflow"
|
||||
|
||||
// One tab-separated line, newline terminated:
|
||||
// eas-egress-guard\t<progname>\t<pid>\t<function>\t<action>\t<peer>\t<caller>,<caller>...\n
|
||||
// Tabs and newlines inside fields are replaced with spaces. Returns the length
|
||||
// written, or -1 when it does not fit.
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,291 @@
|
||||
// Black-box host tests of the built guard, loaded through
|
||||
// DYLD_INSERT_LIBRARIES like the simulator does. Remote calls use fd -1 or are
|
||||
// refused before the kernel; the only real sockets are on loopback. Build and
|
||||
// run with tests/run-guard-tests.sh.
|
||||
//
|
||||
// guard-insert-test <case> <log-path> [<extra-path>]
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendto_fn)(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static struct sockaddr_in6 remote6(int port) {
|
||||
struct sockaddr_in6 a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6;
|
||||
a.sin6_len = sizeof a;
|
||||
a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, "2001:db8::1", &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static int refused_sendto(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
errno = 0;
|
||||
return sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) == -1 && errno == ECONNREFUSED;
|
||||
}
|
||||
|
||||
static off_t file_size(const char *path) {
|
||||
struct stat st;
|
||||
return stat(path, &st) == 0 ? st.st_size : -1;
|
||||
}
|
||||
|
||||
static int count_lines(const char *path, const char *needle) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
int count = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, needle)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Daemon-style cleanup: close every descriptor the process does not know
|
||||
// about, then open an application file. The guard's event must land in the
|
||||
// event log, not in that file.
|
||||
static int fd_reuse(const char *log_path, const char *app_path) {
|
||||
for (int fd = 3; fd < getdtablesize(); fd++) {
|
||||
close(fd);
|
||||
}
|
||||
int app_fd = open(app_path, O_CREAT | O_TRUNC | O_WRONLY, 0600);
|
||||
if (app_fd < 0 || !refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
close(app_fd);
|
||||
if (file_size(app_path) != 0) {
|
||||
printf("FAIL fd-reuse: %lld bytes written into an unrelated file\n",
|
||||
(long long)file_size(app_path));
|
||||
return 1;
|
||||
}
|
||||
if (count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") != 1) {
|
||||
printf("FAIL fd-reuse: event missing from the event log\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Started with stdout closed, as the runner arranges: the process's own stdout
|
||||
// output must not end up in the event log, and the event must.
|
||||
static int low_fd(const char *log_path) {
|
||||
printf("STDOUT-LINE\n");
|
||||
fflush(stdout);
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
if (count_lines(log_path, "STDOUT-LINE") != 0) {
|
||||
return 1;
|
||||
}
|
||||
return count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int nocancel(void) {
|
||||
connect_fn nc_connect = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
sendto_fn nc_sendto = (sendto_fn)dlsym(RTLD_DEFAULT, "sendto$NOCANCEL");
|
||||
sendmsg_fn nc_sendmsg = (sendmsg_fn)dlsym(RTLD_DEFAULT, "sendmsg$NOCANCEL");
|
||||
if (!nc_connect || !nc_sendto || !nc_sendmsg) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in to = remote(443);
|
||||
int failures = 0;
|
||||
errno = 0;
|
||||
if (nc_connect(-1, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: connect$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
errno = 0;
|
||||
if (nc_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendto$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (nc_sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendmsg$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// The shapes the kernel accepts as IPv4/IPv6 despite sa_family == AF_UNSPEC.
|
||||
static int unspec(void) {
|
||||
int failures = 0;
|
||||
struct sockaddr_in to = remote(443);
|
||||
to.sin_family = AF_UNSPEC;
|
||||
int tcp = socket(AF_INET, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp);
|
||||
struct sockaddr_in6 to6 = remote6(443);
|
||||
to6.sin6_family = AF_UNSPEC;
|
||||
int tcp6 = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp6, (struct sockaddr *)&to6, sizeof to6) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP6 connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp6);
|
||||
errno = 0;
|
||||
if (sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendto with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendmsg with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// A UDP socket associated with loopback dissolves the association through
|
||||
// connect(AF_UNSPEC), whatever address bytes follow the family. The guard
|
||||
// must let the kernel do that.
|
||||
static int udp_disconnect(void) {
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
struct sockaddr_in loopback;
|
||||
memset(&loopback, 0, sizeof loopback);
|
||||
loopback.sin_family = AF_INET;
|
||||
loopback.sin_len = sizeof loopback;
|
||||
loopback.sin_port = htons(9);
|
||||
loopback.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in peer;
|
||||
socklen_t peer_len = sizeof peer;
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) != 0) {
|
||||
return 2;
|
||||
}
|
||||
int failures = 0;
|
||||
for (int stale = 0; stale < 2; stale++) {
|
||||
struct sockaddr_in dissolve = stale ? remote(443) : loopback;
|
||||
if (!stale) {
|
||||
memset(&dissolve, 0, sizeof dissolve);
|
||||
dissolve.sin_len = sizeof dissolve;
|
||||
}
|
||||
dissolve.sin_family = AF_UNSPEC;
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
errno = 0;
|
||||
int rc = connect(fd, (struct sockaddr *)&dissolve, sizeof dissolve);
|
||||
int err = errno;
|
||||
peer_len = sizeof peer;
|
||||
int still_connected = getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0;
|
||||
if (rc != -1 || err != EAFNOSUPPORT || still_connected) {
|
||||
printf("FAIL udp-disconnect (%s address): rc=%d errno=%d still_connected=%d\n",
|
||||
stale ? "stale remote" : "zeroed", rc, err, still_connected);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// Past 128 distinct destinations a process writes one overflow line and
|
||||
// nothing more; refusal continues.
|
||||
static int overflow(const char *log_path) {
|
||||
for (int port = 1; port <= 140; port++) {
|
||||
if (!refused_sendto(port)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
int listed = count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:");
|
||||
int overflow_lines = count_lines(log_path, "\toverflow\tblocked\t128 distinct destinations\t");
|
||||
if (listed != 128 || overflow_lines != 1) {
|
||||
printf("FAIL overflow: %d destinations listed, %d overflow line(s)\n", listed, overflow_lines);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The event names the images above the guard, not the guard itself.
|
||||
static int callers(const char *log_path) {
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
FILE *f = fopen(log_path, "r");
|
||||
if (f == NULL) {
|
||||
return 2;
|
||||
}
|
||||
char line[1024];
|
||||
int ok = 0;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, "\tsendto\tblocked\t192.0.2.1:9\t")) {
|
||||
const char *callers_field = strrchr(line, '\t') + 1;
|
||||
ok = strstr(callers_field, "guard-insert-test") != NULL &&
|
||||
strstr(callers_field, "egress-guard") == NULL;
|
||||
if (!ok) {
|
||||
printf("FAIL callers: %s", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 3) {
|
||||
return 2;
|
||||
}
|
||||
const char *name = argv[1];
|
||||
const char *log_path = argv[2];
|
||||
if (strcmp(name, "fd-reuse") == 0) {
|
||||
return argc == 4 ? fd_reuse(log_path, argv[3]) : 2;
|
||||
}
|
||||
if (strcmp(name, "low-fd") == 0) {
|
||||
return low_fd(log_path);
|
||||
}
|
||||
if (strcmp(name, "nocancel") == 0) {
|
||||
return nocancel();
|
||||
}
|
||||
if (strcmp(name, "unspec") == 0) {
|
||||
return unspec();
|
||||
}
|
||||
if (strcmp(name, "udp-disconnect") == 0) {
|
||||
return udp_disconnect();
|
||||
}
|
||||
if (strcmp(name, "callers") == 0) {
|
||||
return callers(log_path);
|
||||
}
|
||||
if (strcmp(name, "overflow") == 0) {
|
||||
return overflow(log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// White-box host tests of guard.c's dedupe lock: the schedules that would
|
||||
// abort the process are forced deterministically. Every case runs in its own
|
||||
// process; sockets are never opened (fd -1), so nothing can leave the host.
|
||||
// Build and run with tests/run-guard-tests.sh.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key);
|
||||
#define eg_seen_insert scheduled_seen_insert
|
||||
#include "../guard.c"
|
||||
#undef eg_seen_insert
|
||||
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#define CONTENTION_THREADS 8
|
||||
#define CONTENTION_KEYS 12
|
||||
#define CONTENTION_ROUNDS 25
|
||||
|
||||
static int interrupt_insert;
|
||||
static int ready_pipe[2];
|
||||
static int release_pipe[2];
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Block mode must refuse before touching the kernel, even on an invalid fd.
|
||||
static void send_remote(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
if (eg_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
_exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_signal(int sig) {
|
||||
(void)sig;
|
||||
send_remote(9);
|
||||
}
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
if (interrupt_insert) {
|
||||
interrupt_insert = 0;
|
||||
raise(SIGUSR1);
|
||||
}
|
||||
return eg_seen_insert(seen, key);
|
||||
}
|
||||
|
||||
static int signal_reentry(void) {
|
||||
signal(SIGUSR1, on_signal);
|
||||
interrupt_insert = 1;
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *hold_lock(void *unused) {
|
||||
(void)unused;
|
||||
char byte = 'x';
|
||||
os_unfair_lock_lock(&eg_lock);
|
||||
(void)write(ready_pipe[1], &byte, 1);
|
||||
(void)read(release_pipe[0], &byte, 1);
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fork_while_locked(void) {
|
||||
if (pipe(ready_pipe) || pipe(release_pipe)) {
|
||||
return 2;
|
||||
}
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, hold_lock, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
char byte;
|
||||
if (read(ready_pipe[0], &byte, 1) != 1) {
|
||||
return 2;
|
||||
}
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
return 2;
|
||||
}
|
||||
if (child == 0) {
|
||||
alarm(5);
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
_exit(0);
|
||||
}
|
||||
(void)write(release_pipe[1], "x", 1);
|
||||
pthread_join(thread, NULL);
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
printf("FAIL fork: child terminated by signal %d\n", WTERMSIG(status));
|
||||
return 1;
|
||||
}
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : 2;
|
||||
}
|
||||
|
||||
static void *hammer(void *unused) {
|
||||
(void)unused;
|
||||
for (int round = 0; round < CONTENTION_ROUNDS; round++) {
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
send_remote(1000 + key);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Under contention a thread may give up its insert; the next attempt on the
|
||||
// same key must then record it, so every key ends up logged exactly once.
|
||||
static int contention(const char *log_path) {
|
||||
pthread_t threads[CONTENTION_THREADS];
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
if (pthread_create(&threads[i], NULL, hammer, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
pthread_join(threads[i], NULL);
|
||||
}
|
||||
FILE *log = fopen(log_path, "r");
|
||||
if (log == NULL) {
|
||||
return 2;
|
||||
}
|
||||
int counts[CONTENTION_KEYS] = {0};
|
||||
int total = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, log)) {
|
||||
total++;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof needle, "\tsendto\tblocked\t192.0.2.1:%d\t", 1000 + key);
|
||||
if (strstr(line, needle)) {
|
||||
counts[key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(log);
|
||||
int failures = 0;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
if (counts[key] != 1) {
|
||||
printf("FAIL contention: 192.0.2.1:%d logged %d times\n", 1000 + key, counts[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
if (total != CONTENTION_KEYS) {
|
||||
printf("FAIL contention: %d lines for %d keys\n", total, CONTENTION_KEYS);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
return 2;
|
||||
}
|
||||
eg_mode = EG_MODE_BLOCK;
|
||||
if (strcmp(argv[1], "signal") == 0) {
|
||||
return signal_reentry();
|
||||
}
|
||||
if (strcmp(argv[1], "fork") == 0) {
|
||||
return fork_while_locked();
|
||||
}
|
||||
if (strcmp(argv[1], "contention") == 0) {
|
||||
return contention(eg_log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Probe run inside a simulator by the egress guard end-to-end test. Exercises
|
||||
// every outbound path the guard must cover and prints one JSON object.
|
||||
//
|
||||
// nettest --proxy-port <port> --udp-port <port>
|
||||
//
|
||||
// proxy-port: a CONNECT proxy on the host's loopback, standing in for the
|
||||
// egress proxy. udp-port: a UDP echo on loopback.
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
var results: [String: String] = [:]
|
||||
let args = CommandLine.arguments
|
||||
func arg(_ name: String) -> Int {
|
||||
guard let i = args.firstIndex(of: name), i + 1 < args.count else { return 0 }
|
||||
return Int(args[i + 1]) ?? 0
|
||||
}
|
||||
let proxyPort = arg("--proxy-port")
|
||||
let udpPort = arg("--udp-port")
|
||||
let remoteHost = "example.com"
|
||||
let remotePort = 443
|
||||
|
||||
func classify(_ error: Error?) -> String {
|
||||
guard let e = error as NSError? else { return "ok" }
|
||||
if e.domain == NSURLErrorDomain && e.code == NSURLErrorCannotConnectToHost { return "refused" }
|
||||
if e.domain == NSPOSIXErrorDomain && e.code == Int(ECONNREFUSED) { return "refused" }
|
||||
return "error(\(e.domain):\(e.code))"
|
||||
}
|
||||
|
||||
// 1. URLSession straight to the internet. Blocked by the guard.
|
||||
func urlSessionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 10
|
||||
c.connectionProxyDictionary = [:] // explicitly no proxy, even if the host has one
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionDirect"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 2. URLSession through a CONNECT proxy on loopback. Allowed: the guard only
|
||||
// sees a loopback connect.
|
||||
func urlSessionProxied() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 15
|
||||
// The kCFNetworkProxies* constants are not exposed to Swift on iOS; these are their values.
|
||||
c.connectionProxyDictionary = ["HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": proxyPort]
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionProxied"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 3. Network.framework connection straight out. Blocked.
|
||||
func nwConnectionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let p = NWParameters.tls; p.preferNoProxies = true
|
||||
let c = NWConnection(host: NWEndpoint.Host(remoteHost), port: NWEndpoint.Port(integerLiteral: UInt16(remotePort)), using: p)
|
||||
var done = false
|
||||
c.stateUpdateHandler = { s in
|
||||
guard !done else { return }
|
||||
switch s {
|
||||
case .ready: done = true; results["nwconnectionDirect"] = "ok"; c.cancel(); g.leave()
|
||||
case .failed(let e): done = true; results["nwconnectionDirect"] = classify(e); g.leave()
|
||||
case .waiting(let e): done = true; results["nwconnectionDirect"] = "waiting(" + classify(e) + ")"; c.cancel(); g.leave()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
c.start(queue: .global())
|
||||
_ = g.wait(timeout: .now() + 15)
|
||||
if results["nwconnectionDirect"] == nil { results["nwconnectionDirect"] = "timeout"; c.cancel() }
|
||||
}
|
||||
|
||||
// 4. Plain BSD TCP connect to a resolved address. Blocked.
|
||||
func bsdConnectDirect() {
|
||||
var hints = addrinfo(); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET
|
||||
var info: UnsafeMutablePointer<addrinfo>? = nil
|
||||
guard getaddrinfo(remoteHost, "443", &hints, &info) == 0, let ai = info else { results["bsdConnectDirect"] = "dns-failed"; return }
|
||||
results["dns"] = "ok"
|
||||
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
|
||||
let rc = connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
|
||||
results["bsdConnectDirect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd); freeaddrinfo(info)
|
||||
}
|
||||
|
||||
// 5. UDP datagram straight out. Blocked. 6. UDP datagram to loopback echo. Allowed.
|
||||
func udp(_ key: String, _ host: String, _ port: Int, expectEcho: Bool) {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(port)).bigEndian
|
||||
inet_pton(AF_INET, host, &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
sendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
if sent < 0 { results[key] = errno == ECONNREFUSED ? "refused" : "errno(\(errno))"; close(fd); return }
|
||||
if expectEcho {
|
||||
var tv = timeval(tv_sec: 3, tv_usec: 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var buf = [UInt8](repeating: 0, count: 16)
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
results[key] = n > 0 ? "ok" : "no-echo"
|
||||
} else { results[key] = "sent" }
|
||||
close(fd)
|
||||
}
|
||||
|
||||
urlSessionDirect()
|
||||
urlSessionProxied()
|
||||
nwConnectionDirect()
|
||||
bsdConnectDirect()
|
||||
udp("udpDirect", "8.8.8.8", 53, expectEcho: false)
|
||||
udp("udpLoopback", "127.0.0.1", udpPort, expectEcho: true)
|
||||
|
||||
// 7. The same literal destination twice: the guard must log it once per process.
|
||||
func bsdConnectLiteral(_ key: String) {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results[key] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
bsdConnectLiteral("literalFirst")
|
||||
bsdConnectLiteral("literalSecond")
|
||||
|
||||
// 8. A sockaddr_in whose family was left AF_UNSPEC: the kernel connects it as
|
||||
// IPv4, so the guard must refuse it. 9. connect(AF_UNSPEC) on a UDP socket
|
||||
// dissolves the association; the guard must leave that to the kernel.
|
||||
func unspecConnect() {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_UNSPEC); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.0.0.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["unspecConnect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
func udpDisconnect() {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(udpPort)).bigEndian
|
||||
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr)
|
||||
let associated = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
var dissolve = sockaddr_in(); dissolve.sin_family = sa_family_t(AF_UNSPEC); dissolve.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &dissolve.sin_addr)
|
||||
let rc = withUnsafePointer(to: &dissolve) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
let err = errno
|
||||
var peer = sockaddr_in(); var peerLength = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let stillConnected = withUnsafeMutablePointer(to: &peer) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getpeername(fd, sa, &peerLength) } } == 0
|
||||
results["udpDisconnect"] = associated != 0 ? "associate-failed"
|
||||
: (rc == -1 && err == EAFNOSUPPORT && !stillConnected) ? "ok" : "errno(\(err)),connected=\(stillConnected)"
|
||||
close(fd)
|
||||
}
|
||||
// 10. The non-cancelable variant of sendto, as libsystem exports it. Blocked.
|
||||
func nocancelSendto() {
|
||||
typealias SendtoFn = @convention(c) (Int32, UnsafeRawPointer?, Int, Int32, UnsafePointer<sockaddr>?, socklen_t) -> Int
|
||||
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sendto$NOCANCEL") else {
|
||||
results["nocancelSendto"] = "no-symbol"; return
|
||||
}
|
||||
let nocancelSendto = unsafeBitCast(symbol, to: SendtoFn.self)
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(53)).bigEndian
|
||||
inet_pton(AF_INET, "8.8.8.8", &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
nocancelSendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["nocancelSendto"] = sent < 0 ? (errno == ECONNREFUSED ? "refused" : "errno(\(errno))") : "sent"
|
||||
close(fd)
|
||||
}
|
||||
unspecConnect()
|
||||
udpDisconnect()
|
||||
nocancelSendto()
|
||||
|
||||
let json = try! JSONSerialization.data(withJSONObject: results, options: [.sortedKeys])
|
||||
print(String(data: json, encoding: .utf8)!)
|
||||
@@ -0,0 +1,143 @@
|
||||
// Host-runnable tests for the guard's policy and formatting. Build and run
|
||||
// with tests/run-policy-tests.sh; every failure prints the failing line.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static struct sockaddr_in v4(const char *ip, int port) {
|
||||
struct sockaddr_in a; memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET; a.sin_len = sizeof a; a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, ip, &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
static struct sockaddr_in6 v6(const char *ip, int port) {
|
||||
struct sockaddr_in6 a; memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6; a.sin6_len = sizeof a; a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, ip, &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static void test_classify(void) {
|
||||
struct sockaddr_in a4; struct sockaddr_in6 a6; struct sockaddr_un un;
|
||||
|
||||
a4 = v4("127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("127.42.0.9", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("192.168.1.10", 8081); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("169.254.1.1", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("224.0.0.251", 5353); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
|
||||
a6 = v6("::1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::", 80); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("2606:4700::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("fe80::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNIX; strcpy(un.sun_path, "/var/run/mDNSResponder");
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
CHECK(eg_classify(NULL, 0) == EG_PASSTHROUGH);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, 4) == EG_PASSTHROUGH); // too short to read
|
||||
|
||||
// AF_UNSPEC is what the kernel makes of it: the family the exact length implies.
|
||||
a4 = v4("93.184.216.34", 443); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4 + 8) == EG_PASSTHROUGH); // kernel: EINVAL
|
||||
a4 = v4("127.0.0.1", 8899); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 0); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK); // UDP disconnect shape
|
||||
a6 = v6("2606:4700::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
}
|
||||
|
||||
static void test_mode(void) {
|
||||
CHECK(eg_parse_mode(NULL) == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("block") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("log") == EG_MODE_LOG);
|
||||
CHECK(eg_parse_mode("LOG") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("permissive") == EG_MODE_BLOCK);
|
||||
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_REMOTE) == 1);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_LOOPBACK) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_PASSTHROUGH) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_LOG, EG_REMOTE) == 0);
|
||||
}
|
||||
|
||||
static void test_format_peer(void) {
|
||||
char out[64];
|
||||
struct sockaddr_in a4 = v4("93.184.216.34", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
struct sockaddr_in6 a6 = v6("2606:4700::1", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, 8) != 0);
|
||||
struct sockaddr_un un; memset(&un, 0, sizeof un); un.sun_family = AF_UNIX;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&un, sizeof un, out, sizeof out) != 0);
|
||||
a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
}
|
||||
|
||||
static void test_seen(void) {
|
||||
static eg_seen_t seen; memset(&seen, 0, sizeof seen);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 1);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 0);
|
||||
CHECK(eg_seen_insert(&seen, "sendto 1.1.1.1:443") == 1);
|
||||
CHECK(seen.count == 2);
|
||||
char key[64];
|
||||
for (int i = 0; i < EG_SEEN_CAPACITY + 5; i++) { snprintf(key, sizeof key, "k%d", i); eg_seen_insert(&seen, key); }
|
||||
CHECK(seen.count == EG_SEEN_CAPACITY);
|
||||
CHECK(seen.overflow == 7); // 2 + 128 + 5 attempts, 128 fit
|
||||
CHECK(eg_seen_insert(&seen, "k0") == 0); // still remembered
|
||||
}
|
||||
|
||||
static void test_format_event(void) {
|
||||
char out[512];
|
||||
const char *callers[] = {"Network", "CFNetwork", "MyApp"};
|
||||
int n = eg_format_event(out, sizeof out, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp\n") == 0);
|
||||
|
||||
const char *tabbed[] = {"a\tb"};
|
||||
n = eg_format_event(out, sizeof out, "we\nird\tname", 1, "sendto", "logged", "1.2.3.4:53", tabbed, 1);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\twe ird name\t1\tsendto\tlogged\t1.2.3.4:53\ta b\n") == 0);
|
||||
|
||||
n = eg_format_event(out, sizeof out, "NoCallers", 7, "connectx", "blocked", "[::2]:80", NULL, 0);
|
||||
CHECK(n > 0 && strcmp(out, "eas-egress-guard\tNoCallers\t7\tconnectx\tblocked\t[::2]:80\t\n") == 0);
|
||||
|
||||
CHECK(eg_format_event(out, 16, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3) == -1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_classify(); test_mode(); test_format_peer(); test_seen(); test_format_event();
|
||||
if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; }
|
||||
printf("egress-guard policy tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard for the host (macOS) and runs the guard tests against it:
|
||||
# white-box lock schedules with guard.c compiled in, and black-box cases with
|
||||
# the built library inserted the way the simulator inserts it. No socket is
|
||||
# ever opened to a non-loopback address.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -dynamiclib policy.c guard.c -o "$out/egress-guard.dylib"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -pthread policy.c tests/guard_test.c -o "$out/guard-test"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 tests/guard_insert_test.c -o "$out/guard-insert-test"
|
||||
|
||||
failures=0
|
||||
report() {
|
||||
if [[ $2 -eq 0 ]]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 (exit $2)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# White-box: guard.c compiled into the test, lock schedules forced.
|
||||
for name in signal fork contention; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_MODE=block EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-test" "$name" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
# Black-box: the built library inserted into a plain process.
|
||||
export DYLD_INSERT_LIBRARIES="$out/egress-guard.dylib"
|
||||
export EAS_EGRESS_GUARD_MODE=block
|
||||
|
||||
status=0
|
||||
: > "$out/fd-reuse.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/fd-reuse.log" \
|
||||
"$out/guard-insert-test" fd-reuse "$out/fd-reuse.log" "$out/application-file" || status=$?
|
||||
report fd-reuse "$status"
|
||||
|
||||
status=0
|
||||
: > "$out/low-fd.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/low-fd.log" \
|
||||
"$out/guard-insert-test" low-fd "$out/low-fd.log" 1>&- || status=$?
|
||||
report low-fd "$status"
|
||||
|
||||
for name in nocancel unspec udp-disconnect callers overflow; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-insert-test" "$name" "$out/$name.log" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
if [[ $failures -ne 0 ]]; then
|
||||
echo "egress-guard guard tests: $failures failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "egress-guard guard tests: all passed"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the policy unit tests for the host (macOS) and runs them.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
clang -std=c11 -Wall -Wextra -Werror -o "$out/policy_test" policy.c tests/policy_test.c
|
||||
"$out/policy_test"
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -45,7 +47,7 @@ describe('projectSources', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -61,7 +63,9 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
...(includeRepositoryUrl
|
||||
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
|
||||
: {}),
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -111,102 +115,88 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
if (['PATH', 'GCS', 'R2'].includes(failure)) {
|
||||
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
|
||||
}
|
||||
if (failure === 'missing-url') {
|
||||
return {
|
||||
data: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -224,7 +214,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -281,7 +270,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -295,7 +284,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
@@ -319,12 +307,11 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -404,6 +391,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -517,6 +508,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -581,6 +576,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
@@ -595,3 +594,56 @@ describe('projectSources', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('local project sources', () => {
|
||||
function createContext(type: ArchiveSourceType): BuildContext<Job> {
|
||||
return new BuildContext(
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
projectArchive: { type, path: '/source.tar.gz' },
|
||||
} as Job,
|
||||
{
|
||||
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('copies and unpacks a PATH source without fetching from www', async () => {
|
||||
await vol.promises.mkdir('/workingdir', { recursive: true });
|
||||
await vol.promises.writeFile('/source.tar.gz', 'local archive');
|
||||
const ctx = createContext(ArchiveSourceType.PATH);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(
|
||||
Object.values(ArchiveSourceType).filter(
|
||||
type => type !== ArchiveSourceType.PATH && type !== ArchiveSourceType.NONE
|
||||
)
|
||||
)('rejects local %s sources before fetching or unpacking', async type => {
|
||||
const ctx = createContext(type);
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: 'SERVER_ERROR',
|
||||
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
|
||||
message: `Expected a PATH project source for a local build, received ${type}.`,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
|
||||
destinationDirectory,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
|
||||
destinationDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { repositoryUrl } = archiveSource;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -19,33 +19,30 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
destinationDirectory: string
|
||||
): // Return type required to make switch exhaustive.
|
||||
Promise<{ handled: boolean }> {
|
||||
let projectArchive: ArchiveSource = ctx.job.projectArchive;
|
||||
if (ctx.isLocal) {
|
||||
console.warn('Local build, skipping project archive refresh');
|
||||
} else {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.NONE) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (ctx.isLocal) {
|
||||
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
|
||||
throw new SystemError(
|
||||
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
|
||||
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
|
||||
);
|
||||
}
|
||||
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
const projectArchive = projectArchiveResult.value;
|
||||
|
||||
switch (projectArchive.type) {
|
||||
case ArchiveSourceType.R2:
|
||||
case ArchiveSourceType.GCS: {
|
||||
throw new Error('Remote project sources should be resolved earlier to URL');
|
||||
}
|
||||
|
||||
case ArchiveSourceType.PATH: {
|
||||
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
case ArchiveSourceType.NONE: {
|
||||
// May be used in no-sources jobs like submission jobs.
|
||||
return { handled: true };
|
||||
@@ -249,7 +246,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -287,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -51,8 +51,10 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
@@ -103,11 +105,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
|
||||
|
||||
const GIT_PROJECT_ARCHIVE = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+4
-1
@@ -142,7 +142,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -16,14 +22,36 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn(),
|
||||
bootAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
configureSimulatorProxyEnvironmentAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
installLocalEgressGuardAsync: jest.fn(),
|
||||
resolveLocalEgressBootEnvironmentAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
|
||||
const mockedInstallGuard = jest.mocked(installLocalEgressGuardAsync);
|
||||
const mockedVerifyGuard = jest.mocked(verifyLocalEgressGuardAsync);
|
||||
const mockedResolveBootEnvironment = jest.mocked(resolveLocalEgressBootEnvironmentAsync);
|
||||
|
||||
// Names resolve to udids the way the step expects; udids pass through.
|
||||
const UDIDS: Record<string, string> = {
|
||||
'iPhone 15': 'base',
|
||||
'eas-simulator-1': 'clone-1',
|
||||
'eas-simulator-2': 'clone-2',
|
||||
};
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
@@ -41,27 +69,41 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.resolveUdidAsync.mockImplementation(
|
||||
async ({ deviceIdentifier }) => (UDIDS[deviceIdentifier] ?? deviceIdentifier) as any
|
||||
);
|
||||
mockedUtils.bootAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockImplementation(async ({ deviceIdentifier }) => ({
|
||||
udid: deviceIdentifier as any,
|
||||
}));
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
mockedConfigureProxyEnvironment.mockResolvedValue(false);
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
mockedVerifyGuard.mockResolvedValue(undefined);
|
||||
mockedResolveBootEnvironment.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
@@ -89,11 +131,6 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
@@ -110,6 +147,84 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the proxy environment and guard between boot and boot completion, then verifies the guard', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
const udids = ['base', 'clone-1', 'clone-2'];
|
||||
expect(
|
||||
mockedUtils.bootAsync.mock.calls.map(([{ deviceIdentifier }]) => deviceIdentifier)
|
||||
).toEqual(udids);
|
||||
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedInstallGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedVerifyGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
for (const [callIndex] of udids.entries()) {
|
||||
const bootOrder = mockedUtils.bootAsync.mock.invocationCallOrder[callIndex];
|
||||
const configureOrder = mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
|
||||
const guardOrder = mockedInstallGuard.mock.invocationCallOrder[callIndex];
|
||||
const bootCompleteOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
const verifyOrder = mockedVerifyGuard.mock.invocationCallOrder[callIndex];
|
||||
const readyOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
|
||||
// launchd is up when boot returns and has spawned nothing yet: that is the
|
||||
// only moment at which its environment reaches every process of the boot.
|
||||
expect(guardOrder).toBeGreaterThan(bootOrder);
|
||||
expect(configureOrder).toBeGreaterThan(guardOrder);
|
||||
expect(configureOrder).toBeLessThan(bootCompleteOrder);
|
||||
// The self-check needs a completed boot and runs before anything else.
|
||||
expect(verifyOrder).toBeGreaterThan(bootCompleteOrder);
|
||||
expect(verifyOrder).toBeLessThan(readyOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with the local egress environment so the first processes inherit it', async () => {
|
||||
mockedResolveBootEnvironment.mockResolvedValue({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
for (const [{ deviceIdentifier, launchdEnvironment }] of mockedUtils.bootAsync.mock.calls) {
|
||||
expect(['base', 'clone-1', 'clone-2']).toContain(deviceIdentifier);
|
||||
expect(launchdEnvironment).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with an empty launchd environment when no local egress session is active', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the guard verification when no local egress session is active', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedVerifyGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the step when the guard cannot be installed or verified', async () => {
|
||||
mockedInstallGuard.mockRejectedValueOnce(new Error('guard library missing'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'guard library missing'
|
||||
);
|
||||
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
mockedVerifyGuard.mockRejectedValueOnce(new Error('self-check failed'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'self-check failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('provides one long-running step', () => {
|
||||
const ctx = { job: {} } as any;
|
||||
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
});
|
||||
});
|
||||
+19
@@ -52,6 +52,25 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
|
||||
@@ -13,6 +13,10 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -66,7 +69,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -121,13 +124,16 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -173,7 +179,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -62,7 +65,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
@@ -138,7 +141,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -149,6 +154,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -177,7 +183,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -79,7 +82,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -112,24 +115,30 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -209,13 +218,16 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -252,7 +264,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,7 +306,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bunx resolve it.`
|
||||
`Continuing and letting bun x resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../utils/localEgressGuard';
|
||||
|
||||
import {
|
||||
IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
@@ -73,9 +81,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
const udid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,9 +125,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
const cloneUdid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -140,6 +150,43 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a device with the local egress environment in place before anything
|
||||
* inside it starts. `simctl boot` returns once the simulator's launchd is up
|
||||
* and before it has spawned anything else, which is the only moment at which
|
||||
* launchd environment reaches every process of the boot. The proxy variables
|
||||
* and the guard are set in that gap; then the boot is waited for and the
|
||||
* guard is verified in a fresh process. Nothing here applies when no local
|
||||
* egress session is active.
|
||||
*/
|
||||
async function bootWithLocalEgressAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
const udid = await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier, env });
|
||||
// The guard and proxy variables ride the boot itself, so launchd has them
|
||||
// before it spawns its first process.
|
||||
const launchdEnvironment = await resolveLocalEgressBootEnvironmentAsync();
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: udid,
|
||||
env,
|
||||
launchdEnvironment: launchdEnvironment ?? {},
|
||||
});
|
||||
// Also set them through launchctl, for a device that was already booted.
|
||||
const guardInstalled = await installLocalEgressGuardAsync({ udid, env, logger });
|
||||
await configureSimulatorProxyEnvironmentAsync({ udid, env, logger });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
if (guardInstalled) {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger });
|
||||
}
|
||||
return udid;
|
||||
}
|
||||
|
||||
async function findMostGenericIphoneUuidAsync({
|
||||
env,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
configureSystemProxyAsync,
|
||||
createChiselAuthfileContents,
|
||||
downloadChiselAsync,
|
||||
generateEgressCredentials,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import { stopLocalEgressGuardRelaysAsync } from '../utils/localEgressGuard';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
|
||||
* on cancellation, while retaining handlers that dispose any eventual result.
|
||||
*/
|
||||
function awaitLocalEgressAcquisitionAsync<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
disposeAbandonedAsync: (result?: T) => Promise<void>,
|
||||
logger: bunyan
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let abandoned = false;
|
||||
const abort = (): void => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abandoned = true;
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
void pending
|
||||
.then(
|
||||
async result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
async error => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err =>
|
||||
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
});
|
||||
// Register before acquisition. Cancellation ends setup promptly; pending
|
||||
// SDK calls retain their own late-result disposal handlers.
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
|
||||
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
|
||||
]);
|
||||
});
|
||||
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
|
||||
const downloadDir = workDir;
|
||||
const chiselPath = await awaitLocalEgressAcquisitionAsync(
|
||||
downloadChiselAsync({ destinationDir: downloadDir, logger }),
|
||||
startupSignal,
|
||||
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const credentials = generateEgressCredentials();
|
||||
const authfilePath = path.join(workDir, 'authfile.json');
|
||||
await fs.promises.writeFile(
|
||||
authfilePath,
|
||||
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const controlPort = await findAvailablePortAsync();
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
|
||||
const started = await startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
startupSignal.throwIfAborted();
|
||||
tunnel = await awaitLocalEgressAcquisitionAsync(
|
||||
startNgrokTunnelAsync({
|
||||
port: controlPort,
|
||||
subdomainPrefix: 'egress',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
signal: startupSignal,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Once the ' +
|
||||
'Simulator boots, proxy environment variables are set inside it for clients that read ' +
|
||||
'them (gRPC, libcurl), and the local egress guard is installed so that connections which ' +
|
||||
'ignore both are refused in the process that makes them and reported here.'
|
||||
);
|
||||
} catch (error) {
|
||||
finishSetup();
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
throw error;
|
||||
} finally {
|
||||
finishSetup();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
|
||||
export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_sandbox',
|
||||
name: 'Start sandbox daemon',
|
||||
__metricsId: 'eas/start_sandbox',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'sandbox_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal }) => {
|
||||
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -37,7 +40,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
@@ -61,10 +64,15 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: webPreview.previewUrl },
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -78,6 +86,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_NO_PROXY,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
configureSimulatorProxyEnvironmentAsync,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
parseChiselFingerprint,
|
||||
parseDefaultRouteInterface,
|
||||
parseDirectSimulatorConnections,
|
||||
parseExitIpResponse,
|
||||
parseNetworkServiceNameForDevice,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../localEgress';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it.each([
|
||||
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
|
||||
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
|
||||
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
|
||||
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
|
||||
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
|
||||
expect(getChiselAssetName({ platform, arch })).toBe(expected);
|
||||
});
|
||||
|
||||
it('rejects platforms without a pinned binary', () => {
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'Local egress is not supported on win32/x64'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
|
||||
'Local egress is not supported on linux/ia32'
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from the pinned GitHub release', () => {
|
||||
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
|
||||
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createChiselAuthfileContents, () => {
|
||||
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
|
||||
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
|
||||
const parsed = JSON.parse(contents) as Record<string, string[]>;
|
||||
expect(parsed['eas:pw']).toHaveLength(2);
|
||||
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
|
||||
|
||||
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
|
||||
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
|
||||
expect(allowed('R:127.0.0.1:8899')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:1024')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:3000')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:8081')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:65535')).toBe(true);
|
||||
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
|
||||
expect(allowed('R:127.0.0.1:1023')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:80')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:0')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:65536')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:88990')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:8899')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:3000')).toBe(false);
|
||||
expect(allowed('R:[::1]:3000')).toBe(false);
|
||||
expect(allowed('R:127x0x0x1:8899')).toBe(false);
|
||||
expect(allowed('R:socks')).toBe(false);
|
||||
expect(allowed('example.com:443')).toBe(false);
|
||||
expect(allowed('127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseChiselFingerprint, () => {
|
||||
it('extracts the fingerprint chisel prints at startup', () => {
|
||||
const output =
|
||||
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
|
||||
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
|
||||
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
|
||||
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
|
||||
});
|
||||
|
||||
it('returns null before the fingerprint line appears', () => {
|
||||
expect(parseChiselFingerprint('')).toBeNull();
|
||||
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDefaultRouteInterface, () => {
|
||||
it('reads the interface from `route -n get default`', () => {
|
||||
const output =
|
||||
' route to: default\n' +
|
||||
'destination: default\n' +
|
||||
' mask: default\n' +
|
||||
' gateway: 172.20.10.1\n' +
|
||||
' interface: en0\n' +
|
||||
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
|
||||
expect(parseDefaultRouteInterface(output)).toBe('en0');
|
||||
});
|
||||
|
||||
it('returns null when there is no default route', () => {
|
||||
expect(
|
||||
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseNetworkServiceNameForDevice, () => {
|
||||
const listOutput =
|
||||
'An asterisk (*) denotes that a network service is disabled.\n' +
|
||||
'(1) Thunderbolt Ethernet Slot 0\n' +
|
||||
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
|
||||
'\n' +
|
||||
'(2) Thunderbolt Bridge\n' +
|
||||
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
|
||||
'\n' +
|
||||
'(3) Wi-Fi\n' +
|
||||
'(Hardware Port: Wi-Fi, Device: en0)\n' +
|
||||
'\n' +
|
||||
'(4) iPhone USB\n' +
|
||||
'(Hardware Port: iPhone USB, Device: en9)\n';
|
||||
|
||||
it('returns the service whose device matches', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
|
||||
});
|
||||
|
||||
it('returns null for an unknown device', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildNetworksetupProxyArgs, () => {
|
||||
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
|
||||
expect(
|
||||
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
|
||||
).toEqual([
|
||||
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectSimulatorProcessIds, () => {
|
||||
const runtimeRoot =
|
||||
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
|
||||
const psOutput =
|
||||
' 1 0 /sbin/launchd\n' +
|
||||
' 501 1 /usr/local/bin/node\n' +
|
||||
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
|
||||
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
|
||||
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
|
||||
' 620 610 /usr/bin/helper\n' +
|
||||
' 700 1 /usr/sbin/lsof\n';
|
||||
|
||||
it('returns every descendant of launchd_sim and nothing else', () => {
|
||||
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
|
||||
});
|
||||
|
||||
it('returns nothing when no simulator is running', () => {
|
||||
expect(
|
||||
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDirectSimulatorConnections, () => {
|
||||
const lsofOutput = [
|
||||
'p610',
|
||||
'cApp',
|
||||
'f10',
|
||||
'PTCP',
|
||||
'n127.0.0.1:52344->127.0.0.1:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f11',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52345->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f12',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52346->104.16.0.1:443',
|
||||
'TST=SYN_SENT',
|
||||
'f13',
|
||||
'PTCP',
|
||||
'n*:8080',
|
||||
'TST=LISTEN',
|
||||
'f14',
|
||||
'PUDP',
|
||||
'n*:5353',
|
||||
'f15',
|
||||
'PUDP',
|
||||
'n192.168.64.2:60000->1.1.1.1:443',
|
||||
'f16',
|
||||
'PTCP',
|
||||
'n[::1]:52347->[::1]:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f17',
|
||||
'PTCP',
|
||||
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
|
||||
'TST=ESTABLISHED',
|
||||
'p700',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52349->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('reports simulator connections to non-loopback peers only', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores processes outside the simulator', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
|
||||
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
]);
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty listing', () => {
|
||||
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressRemoteConfigFields, () => {
|
||||
it('returns nothing without a handoff', () => {
|
||||
expect(buildEgressRemoteConfigFields(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps the handoff to the remoteConfig field names', () => {
|
||||
expect(
|
||||
buildEgressRemoteConfigFields({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
})
|
||||
).toEqual({
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseExitIpResponse, () => {
|
||||
it('reads the ip field', () => {
|
||||
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
|
||||
});
|
||||
|
||||
it('rejects responses without an ip', () => {
|
||||
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
|
||||
'Unexpected exit IP response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress handoff', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the handoff file', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
const handoff = {
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
};
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
|
||||
});
|
||||
|
||||
it('returns null when no egress step ran', async () => {
|
||||
await expect(
|
||||
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed handoff', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildLocalEgressSimulatorEnvironment, () => {
|
||||
it('points every proxy variable at the loopback port and excludes loopback', () => {
|
||||
expect(buildLocalEgressSimulatorEnvironment(8899)).toEqual({
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(configureSimulatorProxyEnvironmentAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
let tempDir: string;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-env-test-'));
|
||||
logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing when no local egress session is active', async () => {
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(tempDir, 'missing.json'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the proxy environment in the simulator when a handoff exists', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([, args]) => args)).toEqual([
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
...Object.entries(buildLocalEgressSimulatorEnvironment(8899)).flat(),
|
||||
],
|
||||
]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('proxy environment variables set')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when launchctl fails', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
mockedSpawn.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('will bypass local egress and exit from this worker')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when the handoff is malformed', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('could not read the local egress handoff')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
output = 'server: Fingerprint pinned-key=\n';
|
||||
stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
|
||||
let resolved = false;
|
||||
const started = startChiselServerAsync(options).then(result => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
output += 'server: Listening on http://127.0.0.1:520010\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(resolved).toBe(false);
|
||||
output += 'server: Listening on http://127.0.0.1:52001\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
|
||||
);
|
||||
expect(options.env.AUTH).toBe('unexpected:password');
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
jest.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress resource lifetime', () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
|
||||
let finish!: () => void;
|
||||
const cleanup = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const signal = registerLocalEgressResources(cleanup);
|
||||
const first = stopLocalEgressResourcesAsync(logger);
|
||||
const second = stopLocalEgressResourcesAsync(logger);
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
let complete = false;
|
||||
void second.then(() => {
|
||||
complete = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(complete).toBe(false);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports cleanup failure without masking the job result', async () => {
|
||||
const error = new Error('cleanup failed');
|
||||
registerLocalEgressResources(async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* End-to-end test of the local egress guard against a real iOS Simulator.
|
||||
* Needs Xcode with a simulator runtime, a shut-down iPhone simulator, and
|
||||
* network access. Opt in with EAS_LOCAL_EGRESS_E2E=1; the test-egress-guard
|
||||
* EAS workflow runs it on macOS.
|
||||
*
|
||||
* Variables handed to launchd at boot cannot be changed afterwards with
|
||||
* `launchctl setenv`, so each configuration gets its own boot.
|
||||
*/
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
type GuardEvent,
|
||||
installLocalEgressGuardAsync,
|
||||
parseGuardLogLine,
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveEgressGuardCheckAsync,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.setTimeout(15 * 60 * 1000);
|
||||
|
||||
const enabled = process.platform === 'darwin' && process.env.EAS_LOCAL_EGRESS_E2E === '1';
|
||||
const describeE2E = enabled ? describe : describe.skip;
|
||||
|
||||
const GUARD_DIR = path.join(__dirname, '..', '..', '..', '..', 'resources', 'egress-guard');
|
||||
const env = process.env;
|
||||
|
||||
function createLogger(): bunyan & { lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
const record = (a: unknown, b?: unknown) => {
|
||||
lines.push(typeof a === 'string' ? a : String(b));
|
||||
};
|
||||
return { info: record, warn: record, debug: record, lines } as any;
|
||||
}
|
||||
|
||||
async function readEventsAsync(logPath: string): Promise<GuardEvent[]> {
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await fs.promises.readFile(logPath, 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split('\n')
|
||||
.map(parseGuardLogLine)
|
||||
.filter((e): e is GuardEvent => e !== null);
|
||||
}
|
||||
|
||||
async function waitForAsync<T>(
|
||||
probe: () => Promise<T | undefined>,
|
||||
{ timeoutMs, intervalMs = 1000 }: { timeoutMs: number; intervalMs?: number }
|
||||
): Promise<T | undefined> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describeE2E('local egress guard in a simulator', () => {
|
||||
let workDir: string;
|
||||
let udid: IosSimulatorUuid;
|
||||
let libraryPath: string;
|
||||
let checkPath: string;
|
||||
let nettestPath: string;
|
||||
let handoffPath: string;
|
||||
let proxyServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let udpServer: dgram.Socket;
|
||||
let udpPort: number;
|
||||
const logger = createLogger();
|
||||
|
||||
/** Shut the device down and boot it again with the given launchd environment. */
|
||||
async function rebootAsync(launchdEnvironment: Record<string, string>): Promise<void> {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: udid, env, launchdEnvironment });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
}
|
||||
|
||||
async function bootEnvironmentAsync(
|
||||
mode: 'block' | 'log',
|
||||
logPath: string
|
||||
): Promise<Record<string, string>> {
|
||||
const variables = await resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
});
|
||||
if (!variables) {
|
||||
throw new Error('The handoff was not written.');
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
async function runNettestAsync(): Promise<Record<string, string>> {
|
||||
const result = await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
nettestPath,
|
||||
'--proxy-port',
|
||||
String(proxyPort),
|
||||
'--udp-port',
|
||||
String(udpPort),
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
const last = result.stdout.trim().split('\n').at(-1) ?? '{}';
|
||||
return JSON.parse(last);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'egress-guard-e2e-'));
|
||||
|
||||
// The guard library and self-check, built on demand.
|
||||
let resolved = await resolveEgressGuardLibraryAsync();
|
||||
if (!resolved) {
|
||||
await spawn('bash', [path.join(GUARD_DIR, 'build.sh')], { env, stdio: 'pipe' });
|
||||
resolved = await resolveEgressGuardLibraryAsync();
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error('egress-guard.dylib is not available and could not be built.');
|
||||
}
|
||||
libraryPath = resolved;
|
||||
const resolvedCheck = await resolveEgressGuardCheckAsync();
|
||||
if (!resolvedCheck) {
|
||||
throw new Error('egress-guard-check is not available and could not be built.');
|
||||
}
|
||||
checkPath = resolvedCheck;
|
||||
|
||||
// The probe binary, compiled for the simulator running on this host.
|
||||
nettestPath = path.join(workDir, 'nettest');
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'-sdk',
|
||||
'iphonesimulator',
|
||||
'swiftc',
|
||||
'-target',
|
||||
`${process.arch === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios15.0-simulator`,
|
||||
path.join(GUARD_DIR, 'tests', 'nettest.swift'),
|
||||
'-o',
|
||||
nettestPath,
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
|
||||
handoffPath = path.join(workDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
// A shut-down iPhone the test can boot as many times as it needs. A device
|
||||
// someone else booted is left alone. Nothing is ever deleted.
|
||||
const booted = new Set(
|
||||
(await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'booted' })).map(d => d.udid)
|
||||
);
|
||||
const available = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
const iphone =
|
||||
available.find(d => /^iPhone \d/.test(d.name) && !booted.has(d.udid)) ??
|
||||
available.find(d => /^iPhone/.test(d.name) && !booted.has(d.udid));
|
||||
if (!iphone) {
|
||||
throw new Error('No shut-down iPhone simulator is available for the test to boot.');
|
||||
}
|
||||
udid = iphone.udid;
|
||||
|
||||
// Loopback stand-ins for the egress proxy and for a local UDP service.
|
||||
proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
proxyServer.on('connect', (req, socket, head) => {
|
||||
const [host, port] = String(req.url).split(':');
|
||||
const upstream = net.connect(Number(port) || 443, host, () => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head.length) {
|
||||
upstream.write(new Uint8Array(head));
|
||||
}
|
||||
upstream.pipe(socket);
|
||||
socket.pipe(upstream);
|
||||
});
|
||||
upstream.on('error', () => socket.destroy());
|
||||
socket.on('error', () => upstream.destroy());
|
||||
});
|
||||
await new Promise<void>(resolve => proxyServer.listen(0, '127.0.0.1', () => resolve()));
|
||||
proxyPort = (proxyServer.address() as net.AddressInfo).port;
|
||||
|
||||
udpServer = dgram.createSocket('udp4');
|
||||
udpServer.on('message', (msg, rinfo) =>
|
||||
udpServer.send(new Uint8Array(msg), rinfo.port, rinfo.address)
|
||||
);
|
||||
await new Promise<void>(resolve => udpServer.bind(0, '127.0.0.1', () => resolve()));
|
||||
udpPort = udpServer.address().port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
proxyServer?.close();
|
||||
udpServer?.close();
|
||||
if (udid) {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
}
|
||||
if (workDir) {
|
||||
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('booted with the guard in launchd, as the worker boots', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'boot.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('block', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
});
|
||||
|
||||
it('covers every process of the boot', async () => {
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage).not.toBeNull();
|
||||
expect(coverage!.covered.length).toBeGreaterThan(50);
|
||||
expect(coverage!.uncovered).toEqual([]);
|
||||
// Refusals were recorded by processes that started during the boot, before
|
||||
// any launchctl call could have reached them.
|
||||
expect((await readEventsAsync(logPath)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses every direct path and leaves loopback and DNS alone', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results).toMatchObject({
|
||||
urlsessionDirect: 'refused',
|
||||
urlsessionProxied: 'ok(200)',
|
||||
bsdConnectDirect: 'refused',
|
||||
dns: 'ok',
|
||||
udpDirect: 'refused',
|
||||
udpLoopback: 'ok',
|
||||
literalFirst: 'refused',
|
||||
literalSecond: 'refused',
|
||||
unspecConnect: 'refused',
|
||||
udpDisconnect: 'ok',
|
||||
nocancelSendto: 'refused',
|
||||
});
|
||||
expect(results.nwconnectionDirect).not.toMatch(/^ok|^timeout/);
|
||||
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThanOrEqual(4);
|
||||
expect(events.every(e => e.action === 'blocked')).toBe(true);
|
||||
// URLSession and Network.framework connects are caught inside Apple's frameworks.
|
||||
expect(
|
||||
events.some(e => e.function === 'connect' && e.callers.some(c => /CFNetwork/.test(c)))
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.some(
|
||||
e =>
|
||||
e.function === 'connect' &&
|
||||
e.callers.some(c => /Network/.test(c) && !/CFNetwork/.test(c))
|
||||
)
|
||||
).toBe(true);
|
||||
// UDP is covered, through the cancelable and non-cancelable entry points.
|
||||
expect(events.some(e => e.function === 'sendto' && e.peer === '8.8.8.8:53')).toBe(true);
|
||||
// An AF_UNSPEC sockaddr is recorded as what the kernel would have used.
|
||||
expect(events.some(e => e.function === 'connect' && e.peer === '1.0.0.1:443')).toBe(true);
|
||||
// The callers name the process and frameworks, never the guard itself.
|
||||
expect(events.every(e => !e.callers.some(c => /egress-guard/.test(c)))).toBe(true);
|
||||
// One line per destination per process, even when the process retries.
|
||||
expect(events.filter(e => e.function === 'connect' && e.peer === '1.1.1.1:443')).toHaveLength(
|
||||
1
|
||||
);
|
||||
// Nothing on loopback is ever reported.
|
||||
expect(events.some(e => /^127\./.test(e.peer))).toBe(false);
|
||||
});
|
||||
|
||||
it('guards processes the simulator launches on its own', async () => {
|
||||
await spawn('xcrun', ['simctl', 'openurl', udid, 'https://example.net/'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const daemonEvent = await waitForAsync(
|
||||
async () =>
|
||||
(await readEventsAsync(logPath)).find(
|
||||
e => e.process !== 'nettest' && e.action === 'blocked'
|
||||
),
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
expect(daemonEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not interfere with the simulator tooling the worker relies on', async () => {
|
||||
const screenshot = path.join(workDir, 'screenshot.png');
|
||||
for (const args of [
|
||||
['simctl', 'spawn', udid, 'launchctl', 'list'],
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
'defaults',
|
||||
'write',
|
||||
'dev.expo.egress-guard-test',
|
||||
'key',
|
||||
'-string',
|
||||
'value',
|
||||
],
|
||||
['simctl', 'spawn', udid, '/usr/bin/env'],
|
||||
['simctl', 'launch', udid, 'com.apple.Preferences'],
|
||||
['simctl', 'io', udid, 'screenshot', screenshot],
|
||||
]) {
|
||||
await expect(spawn('xcrun', args, { env, stdio: 'pipe' })).resolves.toBeDefined();
|
||||
}
|
||||
expect(fs.existsSync(screenshot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted in log mode', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'log-mode.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('log', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'log', checkPath });
|
||||
});
|
||||
|
||||
it('observes without refusing', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results.urlsessionDirect).toBe('ok(200)');
|
||||
expect(results.bsdConnectDirect).toBe('ok');
|
||||
expect(results.udpDirect).toBe('sent');
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events.every(e => e.action === 'logged')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted without the guard, as a device that was already running', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync({});
|
||||
});
|
||||
|
||||
it('is reported as not in effect by the self-check', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath })
|
||||
).rejects.toThrow(/not in effect/);
|
||||
});
|
||||
|
||||
it('can be installed after boot through launchctl for processes started from then on', async () => {
|
||||
const logPath = path.join(workDir, 'install.log');
|
||||
expect(
|
||||
await installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 100,
|
||||
})
|
||||
).toBe(true);
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
|
||||
// The relay turns the guard's events into session log lines.
|
||||
const relayed = await waitForAsync(
|
||||
async () =>
|
||||
logger.lines.find(l =>
|
||||
/refused connect from nettest \(pid \d+\) to 1\.1\.1\.1:443/.test(l)
|
||||
),
|
||||
{ timeoutMs: 5_000, intervalMs: 200 }
|
||||
);
|
||||
expect(relayed).toBeDefined();
|
||||
|
||||
// Processes from before the install are the uncovered set the coverage
|
||||
// report names; nettest itself was covered.
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage!.uncovered.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted with an unwritable event log', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync(
|
||||
await bootEnvironmentAsync('block', path.join(workDir, 'no-such-dir', 'guard.log'))
|
||||
);
|
||||
});
|
||||
|
||||
it('still refuses', async () => {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
expect(results.urlsessionProxied).toBe('ok(200)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
EGRESS_GUARD_LOG_ENV,
|
||||
EGRESS_GUARD_MODE_ENV,
|
||||
GuardEventRelay,
|
||||
GuardLogTailer,
|
||||
buildGuardLaunchdEnvironment,
|
||||
installLocalEgressGuardAsync,
|
||||
mergeGuardCoverageSamples,
|
||||
parseGuardCoverage,
|
||||
parseGuardLogLine,
|
||||
rebindLocalEgressGuardRelays,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { setLaunchdEnvironmentAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockedSetEnv = jest.mocked(IosSimulatorUtils.setLaunchdEnvironmentAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
function createLogger(): bunyan & { lines: { level: string; msg: string }[] } {
|
||||
const lines: { level: string; msg: string }[] = [];
|
||||
const record = (level: string) => (a: unknown, b?: unknown) =>
|
||||
lines.push({ level, msg: typeof a === 'string' ? a : String(b) });
|
||||
return { info: record('info'), warn: record('warn'), debug: record('debug'), lines } as any;
|
||||
}
|
||||
|
||||
const handoff = { url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 };
|
||||
|
||||
describe(buildGuardLaunchdEnvironment, () => {
|
||||
it('inserts the library and passes the log path and mode', () => {
|
||||
expect(
|
||||
buildGuardLaunchdEnvironment({
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardLogLine, () => {
|
||||
it('parses a blocked event with callers', () => {
|
||||
expect(
|
||||
parseGuardLogLine(
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp'
|
||||
)
|
||||
).toEqual({
|
||||
process: 'MyApp',
|
||||
pid: 4242,
|
||||
function: 'connect',
|
||||
action: 'blocked',
|
||||
peer: '93.184.216.34:443',
|
||||
callers: ['Network', 'CFNetwork', 'MyApp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a logged event without callers and IPv6 peers', () => {
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tdaemon\t7\tsendto\tlogged\t[2606:4700::1]:53\t')
|
||||
).toEqual({
|
||||
process: 'daemon',
|
||||
pid: 7,
|
||||
function: 'sendto',
|
||||
action: 'logged',
|
||||
peer: '[2606:4700::1]:53',
|
||||
callers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines that are not guard events', () => {
|
||||
expect(parseGuardLogLine('')).toBeNull();
|
||||
expect(parseGuardLogLine('something else\ta\tb')).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\tnot-a-pid\tconnect\tblocked\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\t1\tconnect\tmaybe\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardEventRelay, () => {
|
||||
const blocked = (process: string, peer: string, pid = 1) => ({
|
||||
process,
|
||||
pid,
|
||||
function: 'connect',
|
||||
action: 'blocked' as const,
|
||||
peer,
|
||||
callers: ['Network', 'CFNetwork'],
|
||||
});
|
||||
|
||||
it('logs the first sighting of a process and destination, then only counts', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443', 2));
|
||||
relay.handle(blocked('MyApp', '2.2.2.2:443'));
|
||||
expect(logger.lines.map(l => l.msg)).toEqual([
|
||||
expect.stringContaining(
|
||||
'refused connect from MyApp (pid 1) to 1.1.1.1:443; callers: Network, CFNetwork'
|
||||
),
|
||||
expect.stringContaining('refused connect from MyApp (pid 1) to 2.2.2.2:443'),
|
||||
]);
|
||||
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('words observed events differently and stops logging past the limit', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger, /* limit */ 1);
|
||||
relay.handle({ ...blocked('MyApp', '1.1.1.1:443'), action: 'logged' });
|
||||
relay.handle({ ...blocked('MyApp', '2.2.2.2:443'), action: 'logged' });
|
||||
expect(logger.lines).toHaveLength(1);
|
||||
expect(logger.lines[0].msg).toContain('observed connect from MyApp');
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 2, distinct: 2, suppressed: 1 });
|
||||
});
|
||||
|
||||
it('explains a process that filled its table instead of counting it as a destination', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle({
|
||||
...blocked('MyApp', '128 distinct destinations'),
|
||||
function: 'overflow',
|
||||
callers: [],
|
||||
});
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'MyApp (pid 1) reached the limit of 128 distinct destinations listed per process; further distinct destinations from it are refused but not listed'
|
||||
);
|
||||
expect(relay.summary()).toEqual({ blocked: 1, logged: 0, distinct: 1, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('ignores the self-check probe, which trips the guard on purpose', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('egress-guard-check', '192.0.2.1:9'));
|
||||
expect(logger.lines).toHaveLength(0);
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 0, distinct: 0, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('writes a summary line', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('daemon', '3.3.3.3:443', 9));
|
||||
relay.logSummary();
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'refused 3 connection attempt(s) to 2 distinct destination(s) from 2 process(es)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardLogTailer, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-tailer-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('delivers whole lines as they are appended, keeps partial lines, and survives truncation', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({ path: file, onLine: l => lines.push(l), intervalMs: 20 });
|
||||
tailer.start();
|
||||
await sleep(50); // file does not exist yet; must not throw
|
||||
await fs.promises.writeFile(file, 'first\nsecond\npart', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second']);
|
||||
await fs.promises.appendFile(file, 'ial\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial']);
|
||||
await fs.promises.writeFile(file, 'after-truncate\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial', 'after-truncate']);
|
||||
await tailer.stopAsync();
|
||||
await fs.promises.appendFile(file, 'late\n', 'utf8');
|
||||
await sleep(60);
|
||||
expect(lines).not.toContain('late');
|
||||
});
|
||||
|
||||
it('flushes what is already in the file when stopped', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({
|
||||
path: file,
|
||||
onLine: l => lines.push(l),
|
||||
intervalMs: 10_000,
|
||||
});
|
||||
tailer.start();
|
||||
await fs.promises.writeFile(file, 'only\n', 'utf8');
|
||||
await tailer.stopAsync();
|
||||
expect(lines).toEqual(['only']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveEgressGuardLibraryAsync, () => {
|
||||
it('returns null when the library is not built', async () => {
|
||||
await expect(
|
||||
resolveEgressGuardLibraryAsync(path.join(os.tmpdir(), 'no-such-dir'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns the packaged path when present', async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bin-'));
|
||||
const lib = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(lib, '');
|
||||
await expect(resolveEgressGuardLibraryAsync(dir)).resolves.toBe(lib);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe(installLocalEgressGuardAsync, () => {
|
||||
let dir: string;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(async () => {
|
||||
mockedSetEnv.mockReset();
|
||||
mockedSetEnv.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-install-'));
|
||||
logger = createLogger();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing without a local egress session', async () => {
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(dir, 'missing.json'),
|
||||
libraryPath: path.join(dir, 'egress-guard.dylib'),
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the session when the library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath: null,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the simulator processes that were already running and are not covered', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/usr/libexec/backboardd',
|
||||
' 3758 1 node',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
});
|
||||
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/2 simulator process\(es\) were already running .* not covered by it: SpringBoard, backboardd\./.test(
|
||||
l.msg
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sets the launchd environment, creates the log file, and relays events into the session log', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 20,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSetEnv).toHaveBeenCalledWith({
|
||||
udid: 'u',
|
||||
env: process.env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath, logPath, mode: 'block' }),
|
||||
});
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
expect(logger.lines.some(l => /guard installed/.test(l.msg) && /block/.test(l.msg))).toBe(true);
|
||||
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/refused connect from MyApp \(pid 4242\) to 1\.1\.1\.1:443/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(logger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('attributes later events and the summary to a rebound logger', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
tailIntervalMs: 20,
|
||||
});
|
||||
|
||||
const sessionLogger = createLogger();
|
||||
rebindLocalEgressGuardRelays(sessionLogger);
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(sessionLogger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(true);
|
||||
expect(logger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(false);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('fails the session when launchctl fails', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSetEnv.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(/Could not install the local egress guard/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveLocalEgressBootEnvironmentAsync, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bootenv-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null without a local egress session', async () => {
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath: path.join(dir, 'missing.json') })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('fails when the guard library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath, libraryPath: null })
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('combines the guard variables with the proxy variables for the handoff port', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync({ ...handoff, port: 8899 }, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: 'localhost,127.0.0.1,::1',
|
||||
NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardCoverage, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Runtimes/x/usr/libexec/backboardd',
|
||||
' 4003 4001 /Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
' 3758 1 node',
|
||||
].join('\n');
|
||||
const lsof = [
|
||||
'p4001',
|
||||
'n/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
'n/usr/lib/dyld',
|
||||
'p4003',
|
||||
'n/Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
'n/Users/expo/build-tools/bin/egress-guard.dylib',
|
||||
'p3758',
|
||||
'n/usr/local/bin/node',
|
||||
].join('\n');
|
||||
|
||||
it('splits simulator processes by whether the guard library is mapped', () => {
|
||||
expect(parseGuardCoverage(ps, lsof)).toEqual({
|
||||
covered: ['Expo Go'],
|
||||
uncovered: ['SpringBoard', 'backboardd'],
|
||||
uncoveredPids: [4001, 4002],
|
||||
});
|
||||
});
|
||||
|
||||
it('only keeps processes uncovered in both samples, so exec-in-progress ones drop out', () => {
|
||||
const first = parseGuardCoverage(ps, lsof);
|
||||
const secondPs = ps + '\n 4009 4000 /Runtimes/x/usr/libexec/newlystarted';
|
||||
const second = parseGuardCoverage(
|
||||
secondPs,
|
||||
lsof + '\np4002\nn/Users/expo/bin/egress-guard.dylib'
|
||||
);
|
||||
expect(mergeGuardCoverageSamples(first, second)).toEqual({
|
||||
covered: ['Expo Go', 'backboardd'],
|
||||
uncovered: ['SpringBoard'],
|
||||
uncoveredPids: [4001],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores launchd's exec trampoline, which has nothing mapped yet", () => {
|
||||
const withTrampoline = ps + '\n 4004 4000 /Runtimes/x/usr/libexec/xpcproxy_sim';
|
||||
expect(parseGuardCoverage(withTrampoline, lsof).uncovered).toEqual([
|
||||
'SpringBoard',
|
||||
'backboardd',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(verifyLocalEgressGuardAsync, () => {
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
logger = createLogger();
|
||||
});
|
||||
|
||||
it('runs the packaged self-check inside the simulator and logs its verdict', async () => {
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'egress-guard-check: guard loaded; non-loopback connections are refused\n',
|
||||
stderr: '',
|
||||
} as any)
|
||||
.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'u', '/w/bin/egress-guard-check', '--mode', 'block'],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/verified in the Simulator: egress-guard-check: guard loaded/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the session with the self-check output when the check fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exited with non-zero code: 2'), {
|
||||
status: 2,
|
||||
stdout: 'egress-guard-check: the guard library is not loaded in this process\n',
|
||||
stderr: '',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
})
|
||||
).rejects.toThrow(/not in effect .*\(exit 2\).*guard library is not loaded/);
|
||||
});
|
||||
|
||||
it('fails the session when the self-check binary is missing', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid: 'u' as any, env: process.env, logger, checkPath: null })
|
||||
).rejects.toThrow(/self-check is not available/);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
@@ -33,6 +34,11 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
|
||||
});
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
@@ -130,6 +136,7 @@ describe(createServeSimArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -329,6 +336,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
@@ -417,6 +427,63 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('carries the serve-sim session token for Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
|
||||
it('fails for Darwin when serve-sim reports no token', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.any(String) }) },
|
||||
expect.any(String)
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs errors after the connection opens', async () => {
|
||||
const server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
||||
await new Promise<void>(resolve => server.once('listening', resolve));
|
||||
const connection = new Promise<WebSocket>(resolve => server.once('connection', resolve));
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
// An invalid opcode makes the client emit a protocol error.
|
||||
const closed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
(socket as any)._socket.write(Buffer.from([0x83, 0x00]));
|
||||
await closed;
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('invalid opcode') }) },
|
||||
expect.stringContaining('invalid opcode')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
it('fails if the server does not complete the handshake', async () => {
|
||||
const sockets = new Set<net.Socket>();
|
||||
const tcpServer = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'Opening handshake has timed out' }) },
|
||||
expect.stringContaining('Opening handshake has timed out')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it.each(['stop', 'abort'])('can %s while the initial connection is pending', async action => {
|
||||
const controller = new AbortController();
|
||||
const tcpServer = net.createServer();
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
if (action === 'abort') {
|
||||
controller.abort();
|
||||
} else {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
|
||||
await expect(connectionError).resolves.toBeInstanceOf(Error);
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('fails when the initial connection cannot be opened', async () => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('ECONNREFUSED') }) },
|
||||
expect.stringContaining('ECONNREFUSED')
|
||||
);
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
|
||||
it('connects with the credential and closes on stop', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
expect(request.url).toBe('/sandbox/connect');
|
||||
expect(request.headers.authorization).toBe('Bearer secret-token');
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
|
||||
await daemon.stopAsync();
|
||||
await socketClosed;
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('cancels the reconnect delay when stopped', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
socket.close();
|
||||
await socketClosed;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
await daemon.stopAsync();
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the session token when the state file carries one', async () => {
|
||||
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
|
||||
|
||||
expect(await readServeSimServersAsync(stateDir)).toEqual([
|
||||
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the state dir does not exist', async () => {
|
||||
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
|
||||
});
|
||||
@@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
|
||||
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
|
||||
});
|
||||
|
||||
it('sends the session token as a bearer header when given one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(sseResponse());
|
||||
await streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: 'https://sim.example',
|
||||
serveSimToken: 'tok-abc',
|
||||
filePath: path.join(workDir, 'auth.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing on a non-ok response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
const filePath = path.join(workDir, 'empty.ndjson');
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
||||
* Requests that use this proxy exit through that machine's network.
|
||||
*
|
||||
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
||||
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
||||
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
||||
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
||||
* client connects, nothing listens on the port and proxied requests fail.
|
||||
*
|
||||
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
||||
* URLSession and other CFNetwork clients) exit from the egress client's network
|
||||
* and fail while the client is disconnected. Requests from libraries that bypass
|
||||
* the system proxy are not covered and exit from this host. Nothing here enforces
|
||||
* routing; the session monitor reports such direct connections instead.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
||||
export const LOCAL_EGRESS_PROXY_PORT = 8899;
|
||||
export const LOCAL_EGRESS_USERNAME = 'eas';
|
||||
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
|
||||
|
||||
export const CHISEL_VERSION = '1.12.0';
|
||||
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
||||
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
|
||||
'chisel_1.12.0_darwin_arm64.gz':
|
||||
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
||||
'chisel_1.12.0_darwin_amd64.gz':
|
||||
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
||||
'chisel_1.12.0_linux_amd64.gz':
|
||||
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
||||
'chisel_1.12.0_linux_arm64.gz':
|
||||
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
||||
};
|
||||
|
||||
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
|
||||
token: string;
|
||||
/** Fingerprint of the tunnel server key, for the client to pin. */
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
platform,
|
||||
arch,
|
||||
}: {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
}): string {
|
||||
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
||||
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
||||
if (!os || !cpu) {
|
||||
throw new SystemError(
|
||||
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
|
||||
);
|
||||
}
|
||||
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
|
||||
}
|
||||
|
||||
export function getChiselDownloadUrl(assetName: string): string {
|
||||
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
async function sha256FileAsync(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(fs.createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function downloadChiselAsync({
|
||||
destinationDir,
|
||||
logger,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
}: {
|
||||
destinationDir: string;
|
||||
logger: bunyan;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
}): Promise<string> {
|
||||
const assetName = getChiselAssetName({ platform, arch });
|
||||
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
||||
if (!expectedSha256) {
|
||||
throw new SystemError(`No pinned checksum for ${assetName}.`);
|
||||
}
|
||||
const url = getChiselDownloadUrl(assetName);
|
||||
const archivePath = path.join(destinationDir, assetName);
|
||||
logger.info(`Downloading ${url}.`);
|
||||
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
|
||||
|
||||
const actualSha256 = await sha256FileAsync(archivePath);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new SystemError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
||||
'The download may be corrupted or tampered with; the session was not started.'
|
||||
);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(destinationDir, 'chisel');
|
||||
await pipeline(
|
||||
fs.createReadStream(archivePath),
|
||||
zlib.createGunzip(),
|
||||
fs.createWriteStream(binaryPath)
|
||||
);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
export function generateEgressCredentials(): { user: string; password: string } {
|
||||
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
|
||||
}
|
||||
|
||||
// Matches 1024-65535: every unprivileged TCP port.
|
||||
const UNPRIVILEGED_PORT_PATTERN =
|
||||
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
||||
|
||||
/**
|
||||
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
||||
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
||||
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
||||
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
||||
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
||||
* requests to the system proxy, so the proxy alone cannot serve them.
|
||||
* Reverse remotes are matched as `R:<interface>:<port>`.
|
||||
*/
|
||||
export function createChiselAuthfileContents({
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
}: {
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
||||
return JSON.stringify({
|
||||
[`${user}:${password}`]: [
|
||||
`^R:${escapedHost}:${port}$`,
|
||||
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChiselFingerprint(output: string): string | null {
|
||||
const match = /Fingerprint\s+(\S+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
chiselPath: string;
|
||||
controlPort: number;
|
||||
authfilePath: string;
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
|
||||
const server = spawnDetached({
|
||||
command: chiselPath,
|
||||
args: [
|
||||
'server',
|
||||
'--host',
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
'--port',
|
||||
String(controlPort),
|
||||
'--reverse',
|
||||
'--authfile',
|
||||
authfilePath,
|
||||
],
|
||||
// AUTH adds an unrestricted user even when --authfile is supplied.
|
||||
env: { ...env, AUTH: '' },
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
||||
break;
|
||||
}
|
||||
const output = server.getOutput();
|
||||
const fingerprint = parseChiselFingerprint(output);
|
||||
// Chisel prints the fingerprint before binding. This line comes from the
|
||||
// child only after its listener succeeds, unlike a probe of a reused port.
|
||||
const listening = output
|
||||
.split('\n')
|
||||
.some(line =>
|
||||
line
|
||||
.trimEnd()
|
||||
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
|
||||
);
|
||||
if (fingerprint && listening) {
|
||||
return { process: server, fingerprint };
|
||||
}
|
||||
await sleepAsync(250);
|
||||
}
|
||||
throw new SystemError(
|
||||
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
|
||||
server.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
await server.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDefaultRouteInterface(routeOutput: string): string | null {
|
||||
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
||||
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
||||
* service name whose device matches.
|
||||
*/
|
||||
export function parseNetworkServiceNameForDevice(
|
||||
listOutput: string,
|
||||
device: string
|
||||
): string | null {
|
||||
const lines = listOutput.split('\n').map(line => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
||||
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
||||
continue;
|
||||
}
|
||||
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
||||
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveActiveNetworkServiceNameAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
||||
const device = parseDefaultRouteInterface(routeResult.stdout);
|
||||
if (!device) {
|
||||
throw new SystemError(
|
||||
'Could not determine the default network interface of the device host, so the system proxy ' +
|
||||
'cannot be configured. Output of `route -n get default`:\n' +
|
||||
(routeResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
||||
if (!service) {
|
||||
throw new SystemError(
|
||||
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
||||
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
||||
(listResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
export function buildNetworksetupProxyArgs({
|
||||
service,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
service: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): string[][] {
|
||||
return [
|
||||
['-setwebproxy', service, host, String(port)],
|
||||
['-setsecurewebproxy', service, host, String(port)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
||||
* simulator reads these settings when it boots, so this must run before
|
||||
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
||||
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
||||
*/
|
||||
export async function configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
port: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ service: string }> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not changing the system proxy.');
|
||||
return { service: '<development>' };
|
||||
}
|
||||
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
||||
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
|
||||
signal?.throwIfAborted();
|
||||
await spawn('networksetup', args, { env, logger, signal });
|
||||
}
|
||||
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
||||
return { service };
|
||||
}
|
||||
|
||||
export const LOCAL_EGRESS_NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
/**
|
||||
* Proxy environment for processes inside the simulator. CFNetwork ignores these
|
||||
* variables and follows the system proxy, but clients with their own network
|
||||
* stack that read them (gRPC and therefore Firestore, libcurl, Go) then send
|
||||
* HTTP CONNECT requests to the same loopback port. Loopback is excluded so the
|
||||
* ports forwarded for `--egress-allow` behave the same on both paths. Lowercase
|
||||
* and uppercase forms are both set because clients differ in which they read.
|
||||
*/
|
||||
export function buildLocalEgressSimulatorEnvironment(port: number): Record<string, string> {
|
||||
const proxyUrl = `http://${LOCAL_EGRESS_PROXY_HOST}:${port}`;
|
||||
return {
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
grpc_proxy: proxyUrl,
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the local egress proxy environment in a booted simulator when a local
|
||||
* egress session is active. Returns false when local egress is not configured
|
||||
* or the environment could not be set. Failure is a warning, not an error: the
|
||||
* session still works, but clients that read these variables then exit from
|
||||
* this worker instead of the egress client, which the warning spells out.
|
||||
*/
|
||||
export async function configureSimulatorProxyEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
}): Promise<boolean> {
|
||||
let handoff: LocalEgressHandoff | null;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not read the local egress handoff, so proxy environment variables were ' +
|
||||
'not set in the Simulator. Clients that read http_proxy/https_proxy (for example gRPC and ' +
|
||||
'libcurl) will bypass local egress and exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
const variables = buildLocalEgressSimulatorEnvironment(handoff.port);
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({ udid, env, variables });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not set proxy environment variables in the Simulator. Clients that read ' +
|
||||
'http_proxy/https_proxy (for example gRPC and libcurl) will bypass local egress and exit from ' +
|
||||
'this worker. CFNetwork clients (WebKit, URLSession) still follow the system proxy.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
`Local egress: proxy environment variables set in the Simulator (${Object.keys(variables).join(
|
||||
', '
|
||||
)}), so clients that read them, such as gRPC and libcurl, also exit from the egress client.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<void> {
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalEgressHandoffAsync(
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<LocalEgressHandoff | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(handoffPath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
|
||||
if (
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
/** remoteConfig fields the CLI needs to start the egress client. */
|
||||
export function buildEgressRemoteConfigFields(
|
||||
handoff: LocalEgressHandoff | null
|
||||
): Record<string, string | number> {
|
||||
if (!handoff) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
}
|
||||
|
||||
// The egress step returns before the session ends, so the resources it created
|
||||
// are held here and released by the session step or the job's finally block.
|
||||
let activeLocalEgressResources:
|
||||
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
|
||||
| undefined;
|
||||
|
||||
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
|
||||
if (activeLocalEgressResources) {
|
||||
throw new SystemError('Local egress resources are already registered for this job.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
activeLocalEgressResources = { stopAsync, controller };
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
|
||||
const resources = activeLocalEgressResources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
resources.controller.abort();
|
||||
resources.stopping ??= Promise.resolve()
|
||||
.then(resources.stopAsync)
|
||||
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
||||
.finally(() => {
|
||||
activeLocalEgressResources = undefined;
|
||||
});
|
||||
await resources.stopping;
|
||||
}
|
||||
|
||||
export async function isPortListeningAsync({
|
||||
host,
|
||||
port,
|
||||
timeoutMs = 1_000,
|
||||
}: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const socket = net.connect({ host, port });
|
||||
const finish = (listening: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExitIpResponse(body: string): string {
|
||||
const parsed = JSON.parse(body) as { ip?: unknown };
|
||||
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
||||
throw new SystemError(`Unexpected exit IP response: ${body}`);
|
||||
}
|
||||
return parsed.ip;
|
||||
}
|
||||
|
||||
export async function fetchExitIpThroughProxyAsync({
|
||||
port,
|
||||
env,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const result = await spawn(
|
||||
'curl',
|
||||
[
|
||||
'-sS',
|
||||
'--max-time',
|
||||
'10',
|
||||
'-x',
|
||||
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
||||
'https://api.ipify.org?format=json',
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseExitIpResponse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
||||
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
||||
* descendant pid; launchd_sim itself opens no network connections.
|
||||
*/
|
||||
export function collectSimulatorProcessIds(psOutput: string): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
const roots: number[] = [];
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const command = match[3].trim();
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
if (path.basename(command) === 'launchd_sim') {
|
||||
roots.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: number[] = [];
|
||||
const seen = new Set<number>(roots);
|
||||
const queue = [...roots];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
||||
if (seen.has(child)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export type DirectSimulatorConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
|
||||
remote: string;
|
||||
};
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
||||
* simulator processes hold to peers outside loopback. Those did not go through
|
||||
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
||||
* client. Listening sockets, unconnected UDP sockets and connections to
|
||||
* loopback (the proxy itself) are ignored.
|
||||
*/
|
||||
export function parseDirectSimulatorConnections(
|
||||
lsofOutput: string,
|
||||
simulatorPids: ReadonlySet<number>
|
||||
): DirectSimulatorConnection[] {
|
||||
const connections: DirectSimulatorConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
switch (field) {
|
||||
case 'p':
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
break;
|
||||
case 'c':
|
||||
command = value;
|
||||
break;
|
||||
case 'f':
|
||||
flush();
|
||||
break;
|
||||
case 'P':
|
||||
protocol = value;
|
||||
break;
|
||||
case 'n':
|
||||
name = value;
|
||||
break;
|
||||
case 'T':
|
||||
if (value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
async function findDirectSimulatorConnectionsAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<DirectSimulatorConnection[]> {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
const pids = collectSimulatorProcessIds(ps.stdout);
|
||||
if (pids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits with 1 when none of the processes holds a matching socket, and
|
||||
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy listener availability, the exit IP observed by a worker request
|
||||
* through it, and simulator connections that bypassed the proxy. Neither check
|
||||
* verifies that proxied simulator requests reach the egress client. Never
|
||||
* rejects: it runs in the background for the whole session.
|
||||
*/
|
||||
export async function monitorLocalEgressAsync({
|
||||
port,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
||||
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
|
||||
if (listening && !connected) {
|
||||
connected = true;
|
||||
logger.info('Local egress proxy listener is available.');
|
||||
try {
|
||||
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
||||
logger.info(
|
||||
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
|
||||
);
|
||||
}
|
||||
} else if (!listening && connected) {
|
||||
connected = false;
|
||||
logger.warn(
|
||||
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
||||
lastEscapeScanAt = Date.now();
|
||||
try {
|
||||
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
||||
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
||||
if (reportedEscapes.has(key)) {
|
||||
continue;
|
||||
}
|
||||
reportedEscapes.add(key);
|
||||
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
||||
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
||||
logger.warn(
|
||||
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
escapeScanBroken = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
collectSimulatorProcessIds,
|
||||
readLocalEgressHandoffAsync,
|
||||
} from './localEgress';
|
||||
|
||||
/**
|
||||
* Worker side of the local egress guard: a dylib injected into every process
|
||||
* the simulator launches, which refuses connections that do not go to
|
||||
* loopback (where the proxy and the `--egress-allow` forwards live) and
|
||||
* records one event per destination per process. This module installs it
|
||||
* through the simulator's launchd environment and relays its events into the
|
||||
* session log. See resources/egress-guard/README.md.
|
||||
*/
|
||||
|
||||
export const EGRESS_GUARD_LIBRARY_FILE = 'egress-guard.dylib';
|
||||
export const EGRESS_GUARD_CHECK_FILE = 'egress-guard-check';
|
||||
export const EGRESS_GUARD_LOG_ENV = 'EAS_EGRESS_GUARD_LOG';
|
||||
export const EGRESS_GUARD_MODE_ENV = 'EAS_EGRESS_GUARD_MODE';
|
||||
export const LOCAL_EGRESS_GUARD_LOG_PATH = path.join(os.tmpdir(), 'eas-local-egress-guard.log');
|
||||
const GUARD_EVENT_PREFIX = 'eas-egress-guard';
|
||||
/** Written once by a process whose per-process table of destinations is full; see policy.h. */
|
||||
const GUARD_OVERFLOW_FUNCTION = 'overflow';
|
||||
const GUARD_RELAY_LOG_LIMIT = 200;
|
||||
const GUARD_TAIL_INTERVAL_MS = 1_000;
|
||||
|
||||
export type EgressGuardMode = 'block' | 'log';
|
||||
|
||||
export function buildGuardLaunchdEnvironment({
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
}: {
|
||||
libraryPath: string;
|
||||
logPath: string;
|
||||
mode: EgressGuardMode;
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
DYLD_INSERT_LIBRARIES: libraryPath,
|
||||
[EGRESS_GUARD_LOG_ENV]: logPath,
|
||||
[EGRESS_GUARD_MODE_ENV]: mode,
|
||||
};
|
||||
}
|
||||
|
||||
export type GuardEvent = {
|
||||
process: string;
|
||||
pid: number;
|
||||
function: string;
|
||||
action: 'blocked' | 'logged';
|
||||
peer: string;
|
||||
/** Image names above the interposer, innermost first. */
|
||||
callers: string[];
|
||||
};
|
||||
|
||||
/** One tab-separated line written by the guard; see policy.h for the format. */
|
||||
export function parseGuardLogLine(line: string): GuardEvent | null {
|
||||
const fields = line.split('\t');
|
||||
if (fields.length < 7 || fields[0] !== GUARD_EVENT_PREFIX) {
|
||||
return null;
|
||||
}
|
||||
const [, process, pidText, fn, action, peer, callerText] = fields;
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || (action !== 'blocked' && action !== 'logged') || !fn || !peer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
process,
|
||||
pid,
|
||||
function: fn,
|
||||
action,
|
||||
peer,
|
||||
callers: callerText ? callerText.split(',').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const PACKAGED_BIN_DIR = path.join(__dirname, '..', '..', '..', 'bin');
|
||||
|
||||
async function resolvePackagedFileAsync(binDir: string, file: string): Promise<string | null> {
|
||||
const filePath = path.join(binDir, file);
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return filePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The packaged library, next to the compiled package like record-sim. */
|
||||
export async function resolveEgressGuardLibraryAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_LIBRARY_FILE);
|
||||
}
|
||||
|
||||
/** The packaged self-check binary, built alongside the library. */
|
||||
export async function resolveEgressGuardCheckAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns guard events into session log lines: one line the first time a
|
||||
* process reaches a destination through a given call, counts after that, and
|
||||
* a summary at the end.
|
||||
*/
|
||||
export class GuardEventRelay {
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly peers = new Set<string>();
|
||||
private readonly processes = new Set<string>();
|
||||
private blocked = 0;
|
||||
private logged = 0;
|
||||
private suppressed = 0;
|
||||
private logger: bunyan;
|
||||
|
||||
constructor(
|
||||
logger: bunyan,
|
||||
private readonly limit: number = GUARD_RELAY_LOG_LIMIT
|
||||
) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log through a different step's logger from now on. Refusals happen for
|
||||
* the life of the session, so they belong to the step that runs the
|
||||
* session, not the one that booted the simulator minutes earlier.
|
||||
*/
|
||||
setLogger(logger: bunyan): void {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
handle(event: GuardEvent): void {
|
||||
if (event.process === EGRESS_GUARD_CHECK_FILE) {
|
||||
// The self-check deliberately trips the guard once; not a bypass.
|
||||
return;
|
||||
}
|
||||
if (event.function === GUARD_OVERFLOW_FUNCTION) {
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${event.process} (pid ${event.pid}) reached the limit of ${event.peer} listed per process; further distinct destinations from it are ${verb} but not listed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.action === 'blocked') {
|
||||
this.blocked++;
|
||||
} else {
|
||||
this.logged++;
|
||||
}
|
||||
this.peers.add(event.peer);
|
||||
this.processes.add(event.process);
|
||||
const key = `${event.process}|${event.function}|${event.peer}`;
|
||||
if (this.seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.seen.add(key);
|
||||
if (this.seen.size > this.limit) {
|
||||
this.suppressed++;
|
||||
return;
|
||||
}
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`
|
||||
);
|
||||
}
|
||||
|
||||
summary(): { blocked: number; logged: number; distinct: number; suppressed: number } {
|
||||
return {
|
||||
blocked: this.blocked,
|
||||
logged: this.logged,
|
||||
distinct: this.peers.size,
|
||||
suppressed: this.suppressed,
|
||||
};
|
||||
}
|
||||
|
||||
logSummary(): void {
|
||||
const { blocked, logged, distinct, suppressed } = this.summary();
|
||||
const observed = logged ? ` and observed ${logged} more without refusing` : '';
|
||||
const dropped = suppressed
|
||||
? ` ${suppressed} further distinct destination(s) were not logged individually.`
|
||||
: '';
|
||||
this.logger.info(
|
||||
`Local egress guard: refused ${blocked} connection attempt(s) to ${distinct} distinct destination(s) from ${this.processes.size} process(es)${observed}.${dropped}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a file the simulator processes append to and delivers whole lines.
|
||||
* Tolerates the file not existing yet, partial trailing lines, and truncation.
|
||||
*/
|
||||
export class GuardLogTailer {
|
||||
private readonly path: string;
|
||||
private readonly onLine: (line: string) => void;
|
||||
private readonly intervalMs: number;
|
||||
private offset = 0;
|
||||
private partial = '';
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private reading: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor({
|
||||
path: filePath,
|
||||
onLine,
|
||||
intervalMs = GUARD_TAIL_INTERVAL_MS,
|
||||
}: {
|
||||
path: string;
|
||||
onLine: (line: string) => void;
|
||||
intervalMs?: number;
|
||||
}) {
|
||||
this.path = filePath;
|
||||
this.onLine = onLine;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.reading = this.reading.then(() => this.readAsync()).catch(() => {});
|
||||
}, this.intervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
async stopAsync(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
await this.reading.catch(() => {});
|
||||
await this.readAsync().catch(() => {});
|
||||
}
|
||||
|
||||
private async readAsync(): Promise<void> {
|
||||
let handle: fs.promises.FileHandle;
|
||||
try {
|
||||
handle = await fs.promises.open(this.path, 'r');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size < this.offset) {
|
||||
// Truncated or replaced; start over.
|
||||
this.offset = 0;
|
||||
this.partial = '';
|
||||
}
|
||||
if (size === this.offset) {
|
||||
return;
|
||||
}
|
||||
const buffer = new Uint8Array(size - this.offset);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.offset);
|
||||
this.offset += bytesRead;
|
||||
const text = this.partial + Buffer.from(buffer.buffer, 0, bytesRead).toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
this.partial = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.length > 0) {
|
||||
this.onLine(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment the simulator's launchd must have from its first process:
|
||||
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
|
||||
* which hands it to launchd before anything is spawned; `launchctl setenv`
|
||||
* after boot only reaches later processes. Returns null when no local egress
|
||||
* session is active. Throws when the guard library is not packaged, since a
|
||||
* local egress session without it would silently leak.
|
||||
*/
|
||||
export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
}: {
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
} = {}): Promise<Record<string, string> | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
}
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
...buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
...buildLocalEgressSimulatorEnvironment(handoff.port),
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = { tailer: GuardLogTailer; relay: GuardEventRelay };
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/**
|
||||
* Install the guard into a simulator when a local egress session is active,
|
||||
* and start relaying its events into the session log. Returns false when
|
||||
* there is no local egress session. Throws when the library is not packaged
|
||||
* or launchd could not be configured: a local egress session without the
|
||||
* guard would silently leak, so it must not start. Only an unwritable event
|
||||
* log is a warning, since refusals still happen and only reporting is lost.
|
||||
*
|
||||
* Call this as soon as `simctl boot` returns: launchd is up and nothing else
|
||||
* has started, so every process the boot spawns inherits the guard. Verify
|
||||
* with `verifyLocalEgressGuardAsync` once boot completes.
|
||||
*/
|
||||
export async function installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
tailIntervalMs,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
tailIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
let handoff;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the local egress handoff, so the guard was not installed. ' +
|
||||
'Connections that bypass the proxy will exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
await logAlreadyRunningProcessesAsync({ env, logger });
|
||||
|
||||
let logWritable = true;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
||||
await fs.promises.appendFile(logPath, '');
|
||||
} catch (err) {
|
||||
logWritable = false;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
'Could not install the local egress guard in the Simulator (launchctl setenv failed), so this local ' +
|
||||
'egress session cannot guarantee that connections bypassing the system proxy are refused. Retry the ' +
|
||||
'session; if it keeps failing, please contact support.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
if (logWritable && !activeRelays.has(logPath)) {
|
||||
const relay = new GuardEventRelay(logger);
|
||||
const tailer = new GuardLogTailer({
|
||||
path: logPath,
|
||||
intervalMs: tailIntervalMs,
|
||||
onLine: line => {
|
||||
const event = parseGuardLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
|
||||
mode === 'block' ? 'refused' : 'observed'
|
||||
} in the process that makes them and reported here as they happen.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the simulator already runs when the guard is installed never get
|
||||
* it. Right after `simctl boot` that is nothing; later it is SpringBoard and
|
||||
* the early daemons, which follow the system proxy anyway. Log them so the
|
||||
* uncovered set is visible rather than assumed.
|
||||
*/
|
||||
async function logAlreadyRunningProcessesAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
let psOutput = '';
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const pids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const names = new Set<string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match && pids.has(Number(match[1]))) {
|
||||
names.add(path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
if (names.size === 0) {
|
||||
logger.info(
|
||||
'Local egress guard: no simulator process was running before the guard was installed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const listed = [...names].sort();
|
||||
const shown =
|
||||
listed.slice(0, 20).join(', ') + (listed.length > 20 ? `, and ${listed.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard: ${names.size} simulator process(es) were already running before the guard was installed and are not covered by it: ${shown}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* launchd's trampoline exists for milliseconds between fork and exec of the
|
||||
* real service, with nothing mapped yet; it never makes a connection itself.
|
||||
*/
|
||||
const COVERAGE_IGNORED_PROCESSES = new Set(['xpcproxy_sim']);
|
||||
|
||||
export type GuardCoverage = {
|
||||
/** Simulator processes with the guard library mapped. */
|
||||
covered: string[];
|
||||
/** Simulator processes without it: started before the guard was installed. */
|
||||
uncovered: string[];
|
||||
/** Pids behind `uncovered`, for comparing samples. */
|
||||
uncoveredPids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which simulator processes have the guard library mapped, from
|
||||
* `ps -axo pid=,ppid=,comm=` and `lsof -nP -a -p <pids> -d txt -F pn` output.
|
||||
* A process is covered when any of its mapped images is the guard library.
|
||||
*/
|
||||
export function parseGuardCoverage(psOutput: string, lsofOutput: string): GuardCoverage {
|
||||
const simulatorPids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const commandsByPid = new Map<number, string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match) {
|
||||
commandsByPid.set(Number(match[1]), path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
const loaded = new Set<number>();
|
||||
let pid: number | null = null;
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
if (line[0] === 'p') {
|
||||
pid = Number(line.slice(1));
|
||||
} else if (line[0] === 'n' && pid !== null && line.endsWith(EGRESS_GUARD_LIBRARY_FILE)) {
|
||||
loaded.add(pid);
|
||||
}
|
||||
}
|
||||
const covered: string[] = [];
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
for (const simulatorPid of simulatorPids) {
|
||||
const name = commandsByPid.get(simulatorPid) ?? String(simulatorPid);
|
||||
if (COVERAGE_IGNORED_PROCESSES.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (loaded.has(simulatorPid)) {
|
||||
covered.push(name);
|
||||
} else {
|
||||
uncovered.push(name);
|
||||
uncoveredPids.push(simulatorPid);
|
||||
}
|
||||
}
|
||||
covered.sort();
|
||||
uncovered.sort();
|
||||
return { covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* A process caught between fork and exec has nothing mapped yet and looks
|
||||
* uncovered for an instant. Two samples a moment apart separate those from
|
||||
* processes that really run without the guard: only pids uncovered in both
|
||||
* count, reported with the later sample's names.
|
||||
*/
|
||||
export function mergeGuardCoverageSamples(
|
||||
first: GuardCoverage,
|
||||
second: GuardCoverage
|
||||
): GuardCoverage {
|
||||
const persistent = new Set(first.uncoveredPids.filter(pid => second.uncoveredPids.includes(pid)));
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
second.uncoveredPids.forEach((pid, index) => {
|
||||
if (persistent.has(pid)) {
|
||||
uncovered.push(second.uncovered[index]);
|
||||
uncoveredPids.push(pid);
|
||||
}
|
||||
});
|
||||
return { covered: second.covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure and log guard coverage across the simulator's processes. Observation
|
||||
* only: the uncovered set is whatever started before the guard was installed,
|
||||
* which is nothing when installation runs right after `simctl boot`.
|
||||
*/
|
||||
export async function reportLocalEgressGuardCoverageAsync({
|
||||
env,
|
||||
logger,
|
||||
sampleIntervalMs = 1_000,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
sampleIntervalMs?: number;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
const first = await sampleGuardCoverageAsync({ env });
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, sampleIntervalMs));
|
||||
const second = await sampleGuardCoverageAsync({ env });
|
||||
const coverage = second ? mergeGuardCoverageSamples(first, second) : first;
|
||||
const total = coverage.covered.length + coverage.uncovered.length;
|
||||
if (coverage.uncovered.length === 0) {
|
||||
logger.info(
|
||||
`Local egress guard coverage: all ${total} simulator process(es) have the guard loaded.`
|
||||
);
|
||||
} else {
|
||||
const shown =
|
||||
coverage.uncovered.slice(0, 20).join(', ') +
|
||||
(coverage.uncovered.length > 20 ? `, and ${coverage.uncovered.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard coverage: ${coverage.covered.length} of ${total} simulator process(es) have the guard loaded. Not covered, started before the guard was installed: ${shown}.`
|
||||
);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
async function sampleGuardCoverageAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
let psOutput: string;
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pids = collectSimulatorProcessIds(psOutput);
|
||||
if (pids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
lsofOutput = (
|
||||
await spawn('lsof', ['-nP', '-a', '-p', pids.join(','), '-d', 'txt', '-F', 'pn'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// lsof exits 1 when some listed pid has already exited; stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
return null;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseGuardCoverage(psOutput, lsofOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the packaged self-check inside the simulator: it must find the guard
|
||||
* loaded in a fresh process and see it behave as `mode` says. Throws when the
|
||||
* check binary is missing or the check fails, which fails the session.
|
||||
*/
|
||||
export async function verifyLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
mode = 'block',
|
||||
checkPath,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
mode?: EgressGuardMode;
|
||||
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
|
||||
checkPath?: string | null;
|
||||
}): Promise<void> {
|
||||
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
|
||||
if (!resolvedCheck) {
|
||||
throw new SystemError(
|
||||
'The local egress guard self-check is not available on this device host, so this session cannot ' +
|
||||
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
|
||||
'this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
let output: string;
|
||||
try {
|
||||
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
output = result.stdout.trim();
|
||||
} catch (err) {
|
||||
const failed = err as { status?: number | null; stdout?: string; stderr?: string };
|
||||
const detail = [failed.stdout, failed.stderr].filter(Boolean).join('\n').trim();
|
||||
throw new SystemError(
|
||||
'The local egress guard is not in effect in the Simulator: the self-check run right after boot ' +
|
||||
`failed${failed.status != null ? ` (exit ${failed.status})` : ''}. Connections that bypass the ` +
|
||||
'system proxy would leave from the device host, so the session was stopped. Retry the session; if it ' +
|
||||
`keeps failing, please contact support.${detail ? ` Self-check output: ${detail}` : ''}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
logger.info(`Local egress guard verified in the Simulator: ${output || 'self-check passed'}.`);
|
||||
await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute guard events to the step that runs the session from now on. The
|
||||
* relay starts under the simulator boot step's logger; once the session step
|
||||
* takes over, its lines should appear under that step in the job log, next
|
||||
* to the rest of the session's output, rather than under a step that already
|
||||
* finished.
|
||||
*/
|
||||
export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
for (const { relay } of activeRelays.values()) {
|
||||
relay.setLogger(logger);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop relaying and write each relay's summary; called from the session cleanup. */
|
||||
export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<void> {
|
||||
const relays = [...activeRelays.values()];
|
||||
activeRelays.clear();
|
||||
for (const { tailer, relay } of relays) {
|
||||
try {
|
||||
await tailer.stopAsync();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the last events from the guard log.'
|
||||
);
|
||||
}
|
||||
relay.logSummary();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import {
|
||||
buildEgressRemoteConfigFields,
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from './localEgress';
|
||||
import { rebindLocalEgressGuardRelays } from './localEgressGuard';
|
||||
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
||||
|
||||
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
||||
export function withLocalEgressSession(
|
||||
fn: NonNullable<BuildFunction['fn']>
|
||||
): NonNullable<BuildFunction['fn']> {
|
||||
return async (context, args) => {
|
||||
try {
|
||||
args.signal?.throwIfAborted();
|
||||
await fn(context, args);
|
||||
} finally {
|
||||
await stopLocalEgressResourcesAsync(context.logger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
||||
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
...options
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
// Guard refusals from here on show up under this step in the job log,
|
||||
// alongside the monitor's reports, instead of under the boot step.
|
||||
rebindLocalEgressGuardRelays(options.logger);
|
||||
options.logger.info(
|
||||
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
||||
'requests are unavailable until it does.'
|
||||
);
|
||||
// The monitor also observes the registered resources' lifetime signal, which
|
||||
// withLocalEgressSession aborts on success, failure, or cancellation.
|
||||
void monitorLocalEgressAsync({
|
||||
port: localEgress.port,
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
@@ -652,6 +653,7 @@ export function createServeSimArgs({
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -705,7 +707,7 @@ export function createExpoDeviceHubArgs({
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -737,7 +739,7 @@ export async function waitForWebPreviewReadyAsync({
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
@@ -754,8 +756,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
@@ -770,6 +772,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -785,6 +789,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
@@ -793,6 +798,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
@@ -806,7 +812,13 @@ async function startWebPreviewWithTunnelAsync(
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
await waitForWebPreviewReadyAsync({ previewServer, serverName, port, timeoutMs });
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
@@ -816,6 +828,7 @@ async function startWebPreviewWithTunnelAsync(
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
@@ -831,6 +844,14 @@ async function startWebPreviewWithTunnelAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -857,6 +878,20 @@ export async function startServeSimWithTunnelAsync(
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
serverUrl: string;
|
||||
reconnectDelayMs: number;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
ready: Promise<void>;
|
||||
stopAsync(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startSandboxDaemonAsync(
|
||||
options: SandboxDaemonOptions
|
||||
): Promise<SandboxDaemon> {
|
||||
options.signal?.throwIfAborted();
|
||||
let socket: WebSocket | undefined;
|
||||
const abortController = new AbortController();
|
||||
let hasConnected = false;
|
||||
let resolveConnected!: () => void;
|
||||
let rejectConnected!: (error: Error) => void;
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
resolveConnected = resolve;
|
||||
rejectConnected = reject;
|
||||
});
|
||||
const stop = (): void => {
|
||||
abortController.abort();
|
||||
socket?.close(1000, 'sandbox stopped');
|
||||
};
|
||||
options.signal?.addEventListener('abort', stop, { once: true });
|
||||
|
||||
const connectionLoop = (async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
try {
|
||||
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
|
||||
handshakeTimeout: 10_000,
|
||||
headers: { Authorization: `Bearer ${options.credential}` },
|
||||
});
|
||||
await waitForOpen(socket);
|
||||
options.logger.info('Sandbox MCP server connected.');
|
||||
hasConnected = true;
|
||||
resolveConnected();
|
||||
await waitForClose(socket);
|
||||
} catch (error: any) {
|
||||
if (!hasConnected) {
|
||||
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.error({ err: error }, message);
|
||||
}
|
||||
rejectConnected(new SystemError(message, { cause: error }));
|
||||
return;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.warn(
|
||||
{ err: error },
|
||||
`Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
try {
|
||||
await setTimeoutAsync(options.reconnectDelayMs, undefined, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ready: connected,
|
||||
async stopAsync(): Promise<void> {
|
||||
options.signal?.removeEventListener('abort', stop);
|
||||
stop();
|
||||
await connectionLoop;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function waitForOpen(socket: WebSocket): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve);
|
||||
socket.once('error', reject);
|
||||
socket.once('close', () => reject(new Error('Sandbox MCP server closed before it connected.')));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(socket: WebSocket): Promise<void> {
|
||||
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('close', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
|
||||
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
|
||||
const END_STREAM_TIMEOUT_MS = 2_000;
|
||||
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
|
||||
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
|
||||
type ServeSimServer = { udid: string; url: string };
|
||||
type ServeSimServer = { udid: string; url: string; token?: string };
|
||||
|
||||
type ServeSimMetricsSession = {
|
||||
logger: bunyan;
|
||||
@@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
|
||||
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
|
||||
const donePromise = streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
filePath,
|
||||
signal: streamSignal,
|
||||
logger,
|
||||
@@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
|
||||
device?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
if (typeof state.device === 'string' && typeof state.url === 'string') {
|
||||
servers.push({ udid: state.device, url: state.url });
|
||||
servers.push({
|
||||
udid: state.device,
|
||||
url: state.url,
|
||||
...(typeof state.token === 'string' ? { token: state.token } : {}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
@@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
|
||||
export async function streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
@@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
|
||||
let receivedData = false;
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
|
||||
return { receivedData, metadata };
|
||||
|
||||
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -185,6 +185,67 @@ export namespace IosSimulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
||||
|
||||
/**
|
||||
* The UDID for a device name or UDID. A name picks the first available
|
||||
* device with that name, as `simctl` itself does.
|
||||
*/
|
||||
export async function resolveUdidAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`No available iOS Simulator is named "${deviceIdentifier}". Run \`xcrun simctl list devices available\` on the device host to see the devices it offers.`
|
||||
);
|
||||
}
|
||||
return device.udid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start booting without waiting for boot to complete; follow with
|
||||
* `startAsync` to wait for it. `launchdEnvironment` is handed to the
|
||||
* simulator's launchd before it spawns anything: `simctl` forwards every
|
||||
* `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
|
||||
* it starts, and for `boot` that process is launchd itself. This is the only
|
||||
* way to give the first processes of a boot an environment; `launchctl
|
||||
* setenv` after boot only reaches processes started later. A device that is
|
||||
* already booted keeps its environment.
|
||||
*/
|
||||
export async function bootAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
launchdEnvironment = {},
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
}
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'boot', deviceIdentifier], { env: bootEnv, stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
const failed = err as { stderr?: string };
|
||||
if (/current state: Booted/.test(failed.stderr ?? '')) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
@@ -285,6 +346,32 @@ export namespace IosSimulatorUtils {
|
||||
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set environment variables in the Simulator's launchd. Every process that
|
||||
* launchd spawns afterwards inherits them: apps launched by SpringBoard
|
||||
* (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
|
||||
* Processes that are already running keep their environment.
|
||||
*/
|
||||
export async function setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
// One invocation for every variable: each `simctl spawn` costs a few
|
||||
// hundred milliseconds on a device host, and this runs in the window
|
||||
// between `simctl boot` returning and launchd spawning the boot's
|
||||
// processes, which must inherit these.
|
||||
const pairs = Object.entries(variables).flat();
|
||||
if (pairs.length === 0) {
|
||||
return;
|
||||
}
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', ...pairs], { env });
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -340,4 +340,136 @@ describe('IosSimulatorUtils', () => {
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
describe(IosSimulatorUtils.setLaunchdEnvironmentAsync, () => {
|
||||
it('sets every variable in the simulator launchd with one invocation', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899', no_proxy: 'localhost,127.0.0.1' },
|
||||
});
|
||||
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
'https_proxy',
|
||||
'http://127.0.0.1:8899',
|
||||
'no_proxy',
|
||||
'localhost,127.0.0.1',
|
||||
],
|
||||
{ env: process.env },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing for an empty variable set', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a launchctl failure', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899' },
|
||||
})
|
||||
).rejects.toThrow('launchctl failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.resolveUdidAsync, () => {
|
||||
it('passes a udid through without listing devices', async () => {
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: '8027F627-9534-4679-85BF-0F14AFF228E8' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a device name to the first available device with that name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [
|
||||
{ udid: 'AAAA', name: 'iPhone 17', isAvailable: true, state: 'Shutdown' },
|
||||
{ udid: 'BBBB', name: 'iPhone Air', isAvailable: true, state: 'Shutdown' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone Air' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('BBBB');
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone 99' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.bootAsync, () => {
|
||||
it('boots without waiting and tolerates an already booted device', async () => {
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env });
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: process.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), {
|
||||
stderr: 'Unable to boot device in current state: Booted',
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), { stderr: 'other' })
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).rejects.toThrow('boot failed');
|
||||
});
|
||||
|
||||
it('hands launchd environment to the boot through SIMCTL_CHILD_ variables', async () => {
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: 'AAAA' as any,
|
||||
env: { PATH: '/usr/bin' },
|
||||
launchdEnvironment: {
|
||||
DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "23.1.0",
|
||||
"version": "24.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "23.1.0"
|
||||
"@expo/steps": "24.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "23.0.0",
|
||||
"version": "24.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "23.1.0",
|
||||
"version": "24.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "23.1.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -240,7 +240,6 @@ describe('Android.JobSchema', () => {
|
||||
username: 'turtle-tutorial',
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'http://localhost:3000',
|
||||
gitRef: 'master',
|
||||
gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchema,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
EnvSchema,
|
||||
SshSettingsZ,
|
||||
@@ -675,3 +679,36 @@ describe('fetchEasCliVersionsAsync', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git archive sources', () => {
|
||||
const source: ArchiveSource = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '1234567890',
|
||||
};
|
||||
|
||||
it('accepts Git job sources without a repository URL', () => {
|
||||
expect(ArchiveSourceSchema.validate(source)).toMatchObject({ value: source });
|
||||
expect(ArchiveSourceSchema.validate(source).error).toBeUndefined();
|
||||
expect(ArchiveSourceSchemaZ.parse(source)).toEqual(source);
|
||||
});
|
||||
|
||||
it('strips the legacy repository URL with the job validation options', () => {
|
||||
const archive = {
|
||||
...source,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
};
|
||||
const result = ArchiveSourceSchema.validate(archive, { stripUnknown: true });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toEqual(source);
|
||||
expect(ArchiveSourceSchemaZ.parse(archive)).toEqual(source);
|
||||
});
|
||||
|
||||
it('still requires the commit and ref', () => {
|
||||
for (const field of ['gitCommitHash', 'gitRef'] as const) {
|
||||
const archive = { ...source, [field]: undefined };
|
||||
expect(ArchiveSourceSchema.validate(archive).error).toBeDefined();
|
||||
expect(ArchiveSourceSchemaZ.safeParse(archive).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -51,7 +50,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -96,7 +94,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -131,7 +128,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -183,7 +179,6 @@ describe('Generic.JobZ', () => {
|
||||
const job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -232,7 +227,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SandboxDaemonCommands, SandboxDaemonRequestZ, SandboxDaemonResponseZ } from '../sandbox';
|
||||
|
||||
describe('sandbox daemon protocol', () => {
|
||||
it('validates command parameters', () => {
|
||||
expect(
|
||||
SandboxDaemonCommands.execCommand.params.parse({
|
||||
cmd: 'pwd',
|
||||
workdir: '/tmp',
|
||||
tty: true,
|
||||
yieldTimeMs: 10,
|
||||
})
|
||||
).toEqual({ cmd: 'pwd', workdir: '/tmp', tty: true, yieldTimeMs: 10 });
|
||||
expect(
|
||||
SandboxDaemonCommands.writeStdin.params.parse({
|
||||
sessionId: 1,
|
||||
chars: '\u0003',
|
||||
yieldTimeMs: 0,
|
||||
})
|
||||
).toEqual({ sessionId: 1, chars: '\u0003', yieldTimeMs: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, terminationSignal: 'SIGTERM' },
|
||||
{ output: '', wallTimeSeconds: 0, sessionId: 1 },
|
||||
])('validates a command result with one terminal state', result => {
|
||||
expect(SandboxDaemonCommands.execCommand.result.parse(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0, sessionId: 1 },
|
||||
])('rejects a command result without exactly one terminal state', result => {
|
||||
expect(() => SandboxDaemonCommands.writeStdin.result.parse(result)).toThrow();
|
||||
});
|
||||
|
||||
it('validates success and error response envelopes', () => {
|
||||
expect(
|
||||
SandboxDaemonRequestZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
});
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } })
|
||||
).toEqual({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } });
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,11 +45,6 @@ export type ArchiveSource =
|
||||
| { type: ArchiveSourceType.PATH; path: string }
|
||||
| {
|
||||
type: ArchiveSourceType.GIT;
|
||||
/**
|
||||
* Url that can be used to clone repository.
|
||||
* It should contain embedded credentials for private registries.
|
||||
*/
|
||||
repositoryUrl: string;
|
||||
/** A Git ref - points to a branch head, tag head or a branch name. */
|
||||
gitRef: string | null;
|
||||
/**
|
||||
@@ -79,7 +74,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
.when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), {
|
||||
then: Joi.object({
|
||||
type: Joi.string().valid(ArchiveSourceType.GIT).required(),
|
||||
repositoryUrl: Joi.string().required(),
|
||||
gitCommitHash: Joi.string().required(),
|
||||
gitRef: Joi.string().allow(null).required(),
|
||||
}),
|
||||
@@ -94,7 +88,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from './compositeFunction';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
export * from './deviceRunSession';
|
||||
export * from './sandbox';
|
||||
|
||||
const version = require('../package.json').version;
|
||||
export { version };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SandboxDaemonCommands = {
|
||||
execCommand: {
|
||||
params: z.object({
|
||||
cmd: z.string().min(1),
|
||||
workdir: z.string().optional(),
|
||||
tty: z.boolean().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
writeStdin: {
|
||||
params: z.object({
|
||||
sessionId: z.number().int().positive(),
|
||||
chars: z.string().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
} as const satisfies Record<string, { params: z.ZodType; result: z.ZodType }>;
|
||||
|
||||
export type SandboxDaemonMethod = keyof typeof SandboxDaemonCommands;
|
||||
|
||||
export type SandboxDaemonCommandParams<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['params']
|
||||
>;
|
||||
|
||||
export type SandboxDaemonCommandResult<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['result']
|
||||
>;
|
||||
|
||||
export const SandboxDaemonRequestZ = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
method: z.string(),
|
||||
params: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type SandboxDaemonRequest = z.output<typeof SandboxDaemonRequestZ>;
|
||||
|
||||
export const SandboxDaemonResponseZ = z.union([
|
||||
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
|
||||
z.strictObject({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
error: z.strictObject({ code: z.number().int(), message: z.string() }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type SandboxDaemonResponse = z.output<typeof SandboxDaemonResponseZ>;
|
||||
@@ -299,4 +299,4 @@ describe('MyCommand', () => {
|
||||
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
|
||||
- **Manifest**: Generated during `prepack` step
|
||||
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
|
||||
- **Node Version**: >= 18.0.0
|
||||
- **Node Version**: ^20.18.3 or >= 22.0.0
|
||||
+688
-332
File diff suppressed because it is too large.
Load diff
Generated
+2039
-1723
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "23.2.0",
|
||||
"version": "24.4.2",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,15 +44,15 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@expo/apple-utils": "2.2.1",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "23.1.0",
|
||||
"@expo/eas-json": "23.1.0",
|
||||
"@expo/eas-build-job": "24.3.0",
|
||||
"@expo/eas-json": "24.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "23.1.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "23.1.0",
|
||||
"@expo/steps": "24.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -266,6 +266,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": "^20.18.3 || >=22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { formatTimespan } from '../../insights/formatTimespan';
|
||||
import { formatTimespan, toTimespanJson } from '../../insights/formatTimespan';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import renderTextTable from '../../utils/renderTextTable';
|
||||
|
||||
@@ -90,11 +90,7 @@ export function buildChannelInsightsJson(summary: ChannelInsightsSummary): objec
|
||||
return {
|
||||
channel: summary.channelName,
|
||||
runtimeVersion: summary.runtimeVersion,
|
||||
timespan: {
|
||||
start: summary.startTime,
|
||||
end: summary.endTime,
|
||||
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
|
||||
},
|
||||
timespan: toTimespanJson(summary),
|
||||
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
|
||||
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
|
||||
mostPopularUpdates: summary.mostPopularUpdates,
|
||||
|
||||
@@ -57,6 +57,7 @@ const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
|
||||
interface BaseGetContextAsyncArgs {
|
||||
nonInteractive: boolean;
|
||||
vcsClientOverride?: Client;
|
||||
projectIdOverride?: string;
|
||||
}
|
||||
|
||||
interface GetContextAsyncArgsWithRequiredServerSideEnvironmentArgument extends BaseGetContextAsyncArgs {
|
||||
@@ -183,6 +184,7 @@ export default abstract class EasCommand extends Command {
|
||||
{
|
||||
nonInteractive,
|
||||
vcsClientOverride,
|
||||
projectIdOverride,
|
||||
// if specified and not null, the env vars from the selected environment will be fetched from the server
|
||||
// to resolve dynamic config (if dynamic config context is used) and enable getServerSideEnvironmentVariablesAsync function (if server side environment variables context is used)
|
||||
withServerSideEnvironment,
|
||||
@@ -206,6 +208,7 @@ export default abstract class EasCommand extends Command {
|
||||
sessionManager: this.sessionManager,
|
||||
analytics: this.analytics,
|
||||
vcsClientOverride,
|
||||
projectIdOverride,
|
||||
withServerSideEnvironment,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
@@ -24,17 +25,24 @@ jest.mock('../../analytics/AnalyticsManager', () => {
|
||||
});
|
||||
jest.mock('../../log');
|
||||
|
||||
let originalProcessArgv: string[];
|
||||
const mockRequestId = uuidv4();
|
||||
|
||||
beforeAll(() => {
|
||||
originalProcessArgv = process.argv;
|
||||
process.argv = [];
|
||||
});
|
||||
/**
|
||||
* A Config that is never loaded. Letting oclif load one discovers and requires
|
||||
* every command in the package, which takes seconds under coverage with a cold
|
||||
* transform cache and has timed out on slow CI runners. Nothing here needs the
|
||||
* command table or hooks.
|
||||
*/
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
process.argv = originalProcessArgv;
|
||||
});
|
||||
let mockConfig: Config;
|
||||
|
||||
const analytics: AnalyticsWithOrchestration = {
|
||||
logEvent: jest.fn((): void => {}),
|
||||
@@ -44,42 +52,57 @@ const analytics: AnalyticsWithOrchestration = {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockConfig = getMockOclifConfig();
|
||||
});
|
||||
|
||||
/**
|
||||
* The config oclif handed to the most recently run command. Command.run returns
|
||||
* a pre-built Config untouched only when it was created by the same @oclif/core
|
||||
* copy the command extends; otherwise it silently builds and fully loads a new
|
||||
* one, bringing back the command discovery this suite avoids.
|
||||
*/
|
||||
let lastCommandConfig: Config | undefined;
|
||||
|
||||
const createTestEasCommand = (): any => {
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
createAnalyticsAsync.mockResolvedValue(analytics);
|
||||
const EasCommand = require('../EasCommand').default;
|
||||
|
||||
class TestEasCommand extends EasCommand {
|
||||
async runAsync(): Promise<void> {}
|
||||
async runAsync(): Promise<void> {
|
||||
lastCommandConfig = this.config;
|
||||
}
|
||||
}
|
||||
|
||||
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
|
||||
return TestEasCommand;
|
||||
};
|
||||
|
||||
// These tests go through the static Command.run rather than constructing an
|
||||
// instance and calling run() like other command tests, because they cover
|
||||
// oclif's catch and finally wiring, which only runs inside the static path.
|
||||
describe('EasCommand', () => {
|
||||
describe('without exceptions', () => {
|
||||
// The first test in this suite should have an increased timeout
|
||||
// because of the implementation of Command from @oclif/command.
|
||||
// It seems that loading config takes significant amount of time
|
||||
// and I'm not sure how to mock it.
|
||||
//
|
||||
// See https://github.com/oclif/command/blob/master/src/command.ts#L80
|
||||
// and look for "Config.load"
|
||||
it('ensures the user data is read', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const SessionManager = jest.requireMock('../../user/SessionManager').default;
|
||||
const sessionManagerSpy = jest.spyOn(SessionManager.prototype, 'getUserAsync');
|
||||
expect(sessionManagerSpy).toBeCalledTimes(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
it('runs with the unloaded config instead of loading one', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(lastCommandConfig).toBe(mockConfig);
|
||||
expect(mockConfig.plugins.size).toBe(0);
|
||||
});
|
||||
|
||||
it('initializes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const { createAnalyticsAsync } = jest.requireMock('../../analytics/AnalyticsManager');
|
||||
expect(createAnalyticsAsync).toHaveBeenCalled();
|
||||
@@ -87,14 +110,14 @@ describe('EasCommand', () => {
|
||||
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.flushAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes Sentry', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
expect(Sentry.flush).toHaveBeenCalled();
|
||||
@@ -102,7 +125,7 @@ describe('EasCommand', () => {
|
||||
|
||||
it('logs events', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('action', {
|
||||
action: `eas ${TestEasCommand.id}`,
|
||||
@@ -114,7 +137,7 @@ describe('EasCommand', () => {
|
||||
it('flushes analytics', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
try {
|
||||
await TestEasCommand.run().then(() => {
|
||||
await TestEasCommand.run([], mockConfig).then(() => {
|
||||
throw new Error('foo');
|
||||
});
|
||||
} catch {}
|
||||
@@ -140,7 +163,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(sentryScope.setTag).toHaveBeenCalledWith('command', TestEasCommand.id);
|
||||
@@ -167,7 +190,7 @@ describe('EasCommand', () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
|
||||
@@ -184,7 +207,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
|
||||
@@ -204,7 +227,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
|
||||
@@ -237,7 +260,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -273,7 +296,7 @@ describe('EasCommand', () => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).toBeCalledWith(
|
||||
@@ -294,7 +317,7 @@ describe('EasCommand', () => {
|
||||
throw new Error('Error message');
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
|
||||
@@ -310,7 +333,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
@@ -340,7 +363,7 @@ describe('EasCommand', () => {
|
||||
throw new CombinedError({ graphQLErrors });
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run();
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch (caughtError) {
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EasPaginatedQueryFlags, getLimitFlagWithCustomValues } from '../pagination';
|
||||
|
||||
async function parseFlagAsync(flag: { parse?: any }, input: string): Promise<number> {
|
||||
return await flag.parse(input, {}, {});
|
||||
}
|
||||
|
||||
describe(getLimitFlagWithCustomValues, () => {
|
||||
const limit = getLimitFlagWithCustomValues({ defaultTo: 50, limit: 100 });
|
||||
|
||||
it('accepts integers within the range', async () => {
|
||||
await expect(parseFlagAsync(limit, '1')).resolves.toBe(1);
|
||||
await expect(parseFlagAsync(limit, '100')).resolves.toBe(100);
|
||||
});
|
||||
|
||||
it('rejects values outside the range', async () => {
|
||||
await expect(parseFlagAsync(limit, '0')).rejects.toThrow('--limit must be between 1 and 100');
|
||||
await expect(parseFlagAsync(limit, '101')).rejects.toThrow('--limit must be between 1 and 100');
|
||||
});
|
||||
|
||||
it('rejects non-integer input instead of passing it to the server', async () => {
|
||||
await expect(parseFlagAsync(limit, '1.5')).rejects.toThrow('Unable to parse 1.5 as an integer');
|
||||
await expect(parseFlagAsync(limit, 'ten')).rejects.toThrow('Unable to parse ten as an integer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EasPaginatedQueryFlags.offset', () => {
|
||||
it('accepts zero and rejects non-integers', async () => {
|
||||
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '0')).resolves.toBe(0);
|
||||
await expect(parseFlagAsync(EasPaginatedQueryFlags.offset, '2.5')).rejects.toThrow(
|
||||
'Unable to parse 2.5 as an integer'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export interface ContextOptions {
|
||||
analytics: Analytics;
|
||||
nonInteractive: boolean;
|
||||
vcsClientOverride?: Client;
|
||||
/** Short-circuits project ID resolution. */
|
||||
projectIdOverride?: string;
|
||||
/**
|
||||
* If specified, env variables from the selected environment will be fetched from the server and used to evaluate the dynamic config.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,14 @@ import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
export class ProjectIdContextField extends ContextField<string> {
|
||||
async getValueAsync({ nonInteractive, sessionManager }: ContextOptions): Promise<string> {
|
||||
async getValueAsync({
|
||||
nonInteractive,
|
||||
sessionManager,
|
||||
projectIdOverride,
|
||||
}: ContextOptions): Promise<string> {
|
||||
if (projectIdOverride) {
|
||||
return projectIdOverride;
|
||||
}
|
||||
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
const expBefore = await getPrivateExpoConfigAsync(projectDir);
|
||||
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ContextOptions } from '../ContextField';
|
||||
import { ProjectIdContextField } from '../ProjectIdContextField';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
|
||||
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
|
||||
|
||||
describe(ProjectIdContextField, () => {
|
||||
it('returns the override without reading the project directory', async () => {
|
||||
const projectId = await new ProjectIdContextField().getValueAsync({
|
||||
projectIdOverride: 'override-project-id',
|
||||
} as ContextOptions);
|
||||
|
||||
expect(projectId).toBe('override-project-id');
|
||||
expect(findProjectDirAndVerifyProjectSetupAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,12 @@ export const EasNonInteractiveAndJsonFlags = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const EasProjectIdFlag = {
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
};
|
||||
|
||||
export function resolveNonInteractiveAndJsonFlags(flags: {
|
||||
json?: boolean;
|
||||
'non-interactive'?: boolean;
|
||||
|
||||
@@ -23,8 +23,8 @@ const parseFlagInputStringAsInteger = (
|
||||
upperLimit: number
|
||||
): number => {
|
||||
const inputAsNumber = Number(input);
|
||||
if (isNaN(inputAsNumber)) {
|
||||
throw new Error(`Unable to parse ${input} as a number`);
|
||||
if (!Number.isInteger(inputAsNumber)) {
|
||||
throw new Error(`Unable to parse ${input} as an integer`);
|
||||
}
|
||||
if (inputAsNumber < lowerLimit || inputAsNumber > upperLimit) {
|
||||
throw new Error(`--${flagName} must be between ${lowerLimit} and ${upperLimit}`);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { GraphqlError } from '../graphql/client';
|
||||
|
||||
export function findPlanGateError(
|
||||
error: unknown,
|
||||
errorCodes: ReadonlySet<string>
|
||||
): GraphQLError | undefined {
|
||||
if (!(error instanceof GraphqlError)) {
|
||||
return undefined;
|
||||
}
|
||||
return error.graphQLErrors.find(e => errorCodes.has(e?.extensions?.errorCode as string));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import { InsightsTimeRangeFlags, resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -28,20 +28,7 @@ export default class ChannelInsights extends EasCommand {
|
||||
description: 'Runtime version to query insights for.',
|
||||
required: true,
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description:
|
||||
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...InsightsTimeRangeFlags,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppObserveErrorSeverity } from '../../../graphql/generated';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../../observe/fetchErrors';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../../observe/formatErrors';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveErrors from '../errors';
|
||||
|
||||
jest.mock('../../../observe/fetchErrors');
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorGroupsTable: jest.fn().mockReturnValue('errors-table'),
|
||||
buildObserveErrorGroupsJson: jest.fn().mockReturnValue({ errors: [], isTruncated: false }),
|
||||
buildObserveErrorOccurrencesTable: jest.fn().mockReturnValue('occurrences-table'),
|
||||
buildObserveErrorOccurrencesJson: jest
|
||||
.fn()
|
||||
.mockReturnValue({ occurrences: [], pageInfo: { hasNextPage: false, endCursor: null } }),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchErrorGroupsAsync = jest.mocked(fetchObserveErrorGroupsAsync);
|
||||
const mockFetchErrorOccurrencesAsync = jest.mocked(fetchObserveErrorOccurrencesAsync);
|
||||
const mockBuildTable = jest.mocked(buildObserveErrorGroupsTable);
|
||||
const mockBuildJson = jest.mocked(buildObserveErrorGroupsJson);
|
||||
const mockBuildOccurrencesTable = jest.mocked(buildObserveErrorOccurrencesTable);
|
||||
const mockBuildOccurrencesJson = jest.mocked(buildObserveErrorOccurrencesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveErrors, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchErrorGroupsAsync.mockResolvedValue({ groups: [], isTruncated: false });
|
||||
mockFetchErrorOccurrencesAsync.mockResolvedValue({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null } as any,
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveErrors {
|
||||
const command = new ObserveErrors(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('fetches error groups and renders a table by default', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with --json', async () => {
|
||||
await createCommand(['--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ errors: [], isTruncated: false });
|
||||
});
|
||||
|
||||
it('maps the --severity flag to the AppObserveErrorSeverity enum', async () => {
|
||||
await createCommand(['--severity', 'fatal']).runAsync();
|
||||
const options = mockFetchErrorGroupsAsync.mock.calls[0][2];
|
||||
expect(options.severity).toBe(AppObserveErrorSeverity.Fatal);
|
||||
});
|
||||
|
||||
it('fetches occurrences (not groups) and renders their table when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1']).runAsync();
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({ fingerprint: 'fp-1' });
|
||||
expect(mockBuildOccurrencesTable).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits occurrences JSON with --fingerprint --json', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildOccurrencesJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
occurrences: [],
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards --limit to the occurrences fetch when --fingerprint is set', async () => {
|
||||
await createCommand(['--fingerprint', 'fp-1', '--limit', '20']).runAsync();
|
||||
expect(mockFetchErrorOccurrencesAsync.mock.calls[0][2]).toMatchObject({
|
||||
fingerprint: 'fp-1',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --limit without --fingerprint', async () => {
|
||||
await expect(createCommand(['--limit', '20']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchErrorOccurrencesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --after without --fingerprint', async () => {
|
||||
await expect(createCommand(['--after', 'cursor-1']).runAsync()).rejects.toThrow(
|
||||
/--after or --limit can only be used in combination with the --fingerprint flag/
|
||||
);
|
||||
expect(mockFetchErrorGroupsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when errors are not available on the plan', async () => {
|
||||
mockFetchErrorGroupsAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(
|
||||
'Subscription to EAS is required for this feature.',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}
|
||||
),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow(/Subscription to EAS is required/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../../observe/formatEvents';
|
||||
import { EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveEvent from '../event';
|
||||
|
||||
jest.mock('../../../observe/formatEvents', () => ({
|
||||
buildObserveEventDetail: jest.fn().mockReturnValue('metric-detail'),
|
||||
buildObserveEventJson: jest.fn().mockReturnValue({ id: 'metric-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatCustomEvents', () => ({
|
||||
buildObserveCustomEventDetail: jest.fn().mockReturnValue('log-detail'),
|
||||
buildObserveCustomEventJson: jest.fn().mockReturnValue({ id: 'log-json' }),
|
||||
}));
|
||||
jest.mock('../../../observe/formatErrors', () => ({
|
||||
buildObserveErrorDetail: jest.fn().mockReturnValue('error-detail'),
|
||||
buildObserveErrorJson: jest.fn().mockReturnValue({ id: 'error-json' }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
logByIdAsync: jest.fn(),
|
||||
metricByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockLogByIdAsync = jest.mocked(ObserveQuery.logByIdAsync);
|
||||
const mockMetricByIdAsync = jest.mocked(ObserveQuery.metricByIdAsync);
|
||||
const mockBuildObserveEventDetail = jest.mocked(buildObserveEventDetail);
|
||||
const mockBuildObserveEventJson = jest.mocked(buildObserveEventJson);
|
||||
const mockBuildObserveCustomEventDetail = jest.mocked(buildObserveCustomEventDetail);
|
||||
const mockBuildObserveCustomEventJson = jest.mocked(buildObserveCustomEventJson);
|
||||
const mockBuildObserveErrorDetail = jest.mocked(buildObserveErrorDetail);
|
||||
const mockBuildObserveErrorJson = jest.mocked(buildObserveErrorJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON.
|
||||
const UUID_ID = '123e4567-e89b-12d3-a456-426614174000';
|
||||
const BASE64_ID = 'eyJhIjoxfQ';
|
||||
const INVALID_ID = 'not a valid id';
|
||||
|
||||
const userEventLog = { id: UUID_ID, __typename: 'AppObserveUserEvent' } as any;
|
||||
const errorLog = { id: UUID_ID, __typename: 'AppObserveError' } as any;
|
||||
|
||||
describe(ObserveEvent, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
mockMetricByIdAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveEvent {
|
||||
const command = new ObserveEvent(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Subscription to EAS is required for this feature. ' +
|
||||
'Subscribe: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_FEATURE_NOT_AVAILABLE_IN_FREE_TIER_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('looks up a UUID id with the log query only', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockLogByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: UUID_ID,
|
||||
});
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveCustomEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders an error detail when a UUID id resolves to an error', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID]).runAsync();
|
||||
expect(mockBuildObserveErrorDetail).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveCustomEventDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks up a base64 id with the metric query only', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID]).runAsync();
|
||||
expect(mockMetricByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: BASE64_ID,
|
||||
});
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveEventDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors immediately for an ID that is neither a UUID nor base64, without querying', async () => {
|
||||
await expect(createCommand([INVALID_ID]).runAsync()).rejects.toThrow(
|
||||
/is not a valid Observe event ID/
|
||||
);
|
||||
expect(mockLogByIdAsync).not.toHaveBeenCalled();
|
||||
expect(mockMetricByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits typed JSON for a metric event with --json', async () => {
|
||||
mockMetricByIdAsync.mockResolvedValue({ id: BASE64_ID } as any);
|
||||
await createCommand([BASE64_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'metric',
|
||||
event: { id: 'metric-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for a log event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(userEventLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveCustomEventJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'log',
|
||||
event: { id: 'log-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits typed JSON for an error event with --json', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(errorLog);
|
||||
await createCommand([UUID_ID, '--json', '--non-interactive']).runAsync();
|
||||
expect(mockBuildObserveErrorJson).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
event: { id: 'error-json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws not-found when a well-formed ID resolves to nothing', async () => {
|
||||
mockLogByIdAsync.mockResolvedValue(null);
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(`No Observe event found with ID "${UUID_ID}"`)
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when the lookup is not available on the plan', async () => {
|
||||
mockLogByIdAsync.mockRejectedValueOnce(planGateError());
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
/Subscription to EAS is required/
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps an unexpected server error with an ID-specific message and preserves the request ID', async () => {
|
||||
const serverError = new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('unexpected server error', null, null, null, null, null, {
|
||||
requestId: 'req-123',
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockLogByIdAsync.mockRejectedValue(serverError);
|
||||
|
||||
await expect(createCommand([UUID_ID]).runAsync()).rejects.toThrow(
|
||||
new RegExp(
|
||||
`Could not retrieve Observe event with ID "${UUID_ID}"[\\s\\S]*unexpected server error \\(Request ID: req-123\\)`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,11 +40,12 @@ describe(ObserveVersions, () => {
|
||||
|
||||
function createCommand(argv: string[]): ObserveVersions {
|
||||
const command = new ObserveVersions(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is a protected method
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
jest
|
||||
.spyOn(command as any, 'getContextAsync')
|
||||
.mockImplementation(async (_commandClass: any, { projectIdOverride }: any) => ({
|
||||
projectId: projectIdOverride ?? projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
}));
|
||||
return command;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AppObserveErrorSeverity } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
fetchObserveErrorGroupsAsync,
|
||||
fetchObserveErrorOccurrencesAsync,
|
||||
} from '../../observe/fetchErrors';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveErrorGroupsJson,
|
||||
buildObserveErrorGroupsTable,
|
||||
buildObserveErrorOccurrencesJson,
|
||||
buildObserveErrorOccurrencesTable,
|
||||
} from '../../observe/formatErrors';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const SEVERITY_BY_FLAG: Record<string, AppObserveErrorSeverity> = {
|
||||
fatal: AppObserveErrorSeverity.Fatal,
|
||||
error: AppObserveErrorSeverity.Error,
|
||||
};
|
||||
|
||||
const DEFAULT_OCCURRENCES_LIMIT = 10;
|
||||
|
||||
export default class ObserveErrors extends EasCommand {
|
||||
static override description =
|
||||
'display error and exception issue groups (grouped by fingerprint) for the app';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
fingerprint: Flags.string({
|
||||
description:
|
||||
'Show individual occurrences (with stack traces) for this error group fingerprint instead of the grouped summary',
|
||||
required: false,
|
||||
}),
|
||||
severity: Flags.option({
|
||||
description: 'Filter by severity (ignored when --fingerprint is set)',
|
||||
options: Object.keys(SEVERITY_BY_FLAG),
|
||||
required: false,
|
||||
})(),
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_OCCURRENCES_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveErrors);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveErrors, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (!flags.fingerprint && (flags.after || flags.limit)) {
|
||||
throw new Error(
|
||||
'--after or --limit can only be used in combination with the --fingerprint flag.'
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
|
||||
if (flags.fingerprint) {
|
||||
const { occurrences, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorOccurrencesAsync(graphqlClient, projectId, {
|
||||
fingerprint: flags.fingerprint as string,
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
limit: flags.limit ?? DEFAULT_OCCURRENCES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorOccurrencesJson(occurrences, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveErrorOccurrencesTable(occurrences, pageInfo, {
|
||||
fingerprint: flags.fingerprint,
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { groups, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveErrorGroupsAsync(graphqlClient, projectId, {
|
||||
startTime,
|
||||
endTime,
|
||||
platforms: observePlatformsFromFlag(flags.platform),
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
...(flags.severity && { severity: SEVERITY_BY_FLAG[flags.severity] }),
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveErrorGroupsJson(groups, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorGroupsTable(groups, { daysBack, startTime, endTime }));
|
||||
if (isTruncated) {
|
||||
Log.warn('Result is truncated; not all error groups are shown.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { validate as isUuid } from 'uuid';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { GraphqlError } from '../../graphql/client';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
buildObserveCustomEventDetail,
|
||||
buildObserveCustomEventJson,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { buildObserveErrorDetail, buildObserveErrorJson } from '../../observe/formatErrors';
|
||||
import { buildObserveEventDetail, buildObserveEventJson } from '../../observe/formatEvents';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ObserveEvent extends EasCommand {
|
||||
static override description =
|
||||
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
description: 'ID of the event to display',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvent);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveEvent, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const id = args.id;
|
||||
|
||||
// A log event ID (user event or error) is a UUID; a metric event ID is
|
||||
// base64url-encoded JSON. Route to the matching query so we never issue the
|
||||
// query that is guaranteed to fail for this ID, and reject anything that is
|
||||
// neither up front.
|
||||
if (isUuid(id)) {
|
||||
const log = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.logByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!log) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (log.__typename === 'AppObserveError') {
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'error', event: buildObserveErrorJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveErrorDetail(log));
|
||||
}
|
||||
} else if (json) {
|
||||
printJsonOnlyOutput({ type: 'log', event: buildObserveCustomEventJson(log) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveCustomEventDetail(log));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsesAsBase64(id)) {
|
||||
const event = await fetchObserveEventAsync(id, () =>
|
||||
ObserveQuery.metricByIdAsync(graphqlClient, { appId: projectId, id })
|
||||
);
|
||||
if (!event) {
|
||||
throw eventNotFoundError(id);
|
||||
}
|
||||
if (json) {
|
||||
printJsonOnlyOutput({ type: 'metric', event: buildObserveEventJson(event) });
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveEventDetail(event));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new EasCommandError(
|
||||
`"${id}" is not a valid Observe event ID. IDs come from \`eas observe:events\` or \`eas observe:session\`.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A metric event ID is canonical base64url (of JSON). Round-tripping rejects
|
||||
* strings that only coincidentally contain base64 characters, so a value that
|
||||
* is neither a UUID nor this is treated as an invalid ID.
|
||||
*/
|
||||
function parsesAsBase64(id: string): boolean {
|
||||
if (id.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return Buffer.from(id, 'base64url').toString('base64url') === id;
|
||||
}
|
||||
|
||||
function eventNotFoundError(id: string): EasCommandError {
|
||||
return new EasCommandError(
|
||||
`No Observe event found with ID "${id}". IDs come from \`eas observe:events\` or \`eas observe:session\`, and events age out of retention.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an Observe event lookup, translating plan-gate rejections to their
|
||||
* upgrade message and any other server error into an actionable message that
|
||||
* preserves the underlying error and request ID for support.
|
||||
*/
|
||||
async function fetchObserveEventAsync<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await withObservePlanGateHandlingAsync(fn);
|
||||
} catch (error) {
|
||||
if (error instanceof EasCommandError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Could not retrieve Observe event with ID "${id}". ` +
|
||||
'The ID may be invalid or the event may not exist (events also age out of retention). ' +
|
||||
'Verify it was copied in full from `eas observe:events` or `eas observe:session`.' +
|
||||
`\n\n${describeObserveServerError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable description from a GraphQL/server error, including
|
||||
* the request ID(s) so a support request can reference them. Falls back to the
|
||||
* error's own message for non-GraphQL errors.
|
||||
*/
|
||||
function describeObserveServerError(error: unknown): string {
|
||||
if (error instanceof GraphqlError && error.graphQLErrors.length > 0) {
|
||||
return error.graphQLErrors
|
||||
.map(graphQLError => {
|
||||
const message = graphQLError.message.replace('[GraphQL] ', '');
|
||||
const requestId = graphQLError.extensions?.requestId;
|
||||
return requestId ? `${message} (Request ID: ${String(requestId)})` : message;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Args, Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -67,7 +66,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
default: false,
|
||||
}),
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -76,10 +75,6 @@ export default class ObserveEvents extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
@@ -90,12 +85,12 @@ export default class ObserveEvents extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveEvents,
|
||||
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveEvents, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
@@ -10,7 +11,6 @@ import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -64,7 +63,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
})(),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -73,20 +72,16 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetricsSummary);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetricsSummary,
|
||||
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveMetricsSummary, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -28,7 +28,6 @@ import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../obs
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import { observePlatformTargetsFromFlag, observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -64,7 +63,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -73,20 +72,16 @@ export default class ObserveMetrics extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveMetrics);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetrics,
|
||||
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveMetrics, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Flags } from '@oclif/core';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -71,7 +70,7 @@ export default class ObserveRoutes extends EasCommand {
|
||||
multiple: true,
|
||||
}),
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -80,20 +79,16 @@ export default class ObserveRoutes extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveRoutes);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveRoutes, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -16,11 +17,7 @@ import {
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
verifyObserveSessionAccessAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { ObserveEnvironmentFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
@@ -33,7 +30,6 @@ import {
|
||||
isKnownMetricName,
|
||||
resolveMetricName,
|
||||
} from '../../observe/metricNames';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { ExpoChoice, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -71,7 +67,7 @@ export default class ObserveSession extends EasCommand {
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -80,20 +76,16 @@ export default class ObserveSession extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveSession);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveSession,
|
||||
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveSession, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
@@ -8,12 +9,10 @@ import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { observePlatformTargetsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -24,7 +23,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
...ObservePlatformFlag,
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveEnvironmentFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -33,20 +32,16 @@ export default class ObserveVersions extends EasCommand {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveVersions,
|
||||
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveVersions, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import Log from '../../../log';
|
||||
import { runLocalEgressAsync } from '../../../simulator/egress';
|
||||
import { loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import SimulatorEgress from '../egress';
|
||||
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
runLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const shellSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-b',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: '',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-b.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-b',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-b',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8899',
|
||||
};
|
||||
const fileSession = {
|
||||
EAS_SIMULATOR_SESSION_ID: 'session-a',
|
||||
EAS_SIMULATOR_EGRESS_ALLOW: 'localhost:3000',
|
||||
EAS_SIMULATOR_EGRESS_URL: 'https://egress-a.example.test',
|
||||
EAS_SIMULATOR_EGRESS_TOKEN: 'token-a',
|
||||
EAS_SIMULATOR_EGRESS_FINGERPRINT: 'fingerprint-a',
|
||||
EAS_SIMULATOR_EGRESS_PORT: '8898',
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): SimulatorEgress {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
const command = new SimulatorEgress(argv, config);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({ projectDir: '/test/project' });
|
||||
return command;
|
||||
}
|
||||
|
||||
describe(SimulatorEgress, () => {
|
||||
const previousEnv = Object.fromEntries(
|
||||
Object.keys(shellSession).map(key => [key, process.env[key]])
|
||||
);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.assign(process.env, shellSession);
|
||||
jest.mocked(loadSimulatorEnvAsync).mockImplementation(async () => {
|
||||
Object.assign(process.env, fileSession);
|
||||
});
|
||||
jest.mocked(runLocalEgressAsync).mockResolvedValue();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the complete exported session tuple without loading a stale dotenv session in env mode', async () => {
|
||||
await createCommand(['--config-type', 'env']).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: shellSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: shellSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: shellSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8899,
|
||||
allow: [],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-b'));
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas simulator:stop --id session-b')
|
||||
);
|
||||
});
|
||||
|
||||
it.each([[], ['--config-type', 'dotenv']])(
|
||||
'loads the managed session by default or with explicit dotenv selection (%j)',
|
||||
async (...argv) => {
|
||||
await createCommand(argv).runAsync();
|
||||
|
||||
expect(loadSimulatorEnvAsync).toHaveBeenCalledWith('/test/project');
|
||||
expect(runLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: fileSession.EAS_SIMULATOR_EGRESS_URL,
|
||||
token: fileSession.EAS_SIMULATOR_EGRESS_TOKEN,
|
||||
fingerprint: fileSession.EAS_SIMULATOR_EGRESS_FINGERPRINT,
|
||||
port: 8898,
|
||||
allow: ['localhost:3000'],
|
||||
})
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('session-a'));
|
||||
}
|
||||
);
|
||||
|
||||
it('does not fill missing exported credentials from a stale file in env mode', async () => {
|
||||
delete process.env.EAS_SIMULATOR_EGRESS_TOKEN;
|
||||
|
||||
await expect(createCommand(['--config-type', 'env']).runAsync()).rejects.toThrow(
|
||||
'no egress client to run'
|
||||
);
|
||||
|
||||
expect(loadSimulatorEnvAsync).not.toHaveBeenCalled();
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user