mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
clientinject: xPilot version-pinned adapter scaffold
Add multi-client xPilot path (research + adapter/profile as evidence allows). Prove registry is not vPilot-only; refuse unknown PE hashes.
This commit is contained in:
@@ -14,13 +14,16 @@ func TestBuildClientSlots_WithDefaultAdapters(t *testing.T) {
|
||||
}
|
||||
slots := BuildClientSlots(eng.Adapters)
|
||||
var enabled, coming int
|
||||
var hasVPilot bool
|
||||
var hasVPilot, hasXPilot bool
|
||||
for _, s := range slots {
|
||||
if s.Enabled {
|
||||
enabled++
|
||||
if s.ID == "vpilot" {
|
||||
hasVPilot = true
|
||||
}
|
||||
if s.ID == "xpilot" {
|
||||
hasXPilot = true
|
||||
}
|
||||
if strings.Contains(SlotLabel(s), "Coming soon") {
|
||||
t.Fatalf("enabled slot labeled coming soon: %+v", s)
|
||||
}
|
||||
@@ -29,12 +32,18 @@ func TestBuildClientSlots_WithDefaultAdapters(t *testing.T) {
|
||||
if !strings.Contains(SlotLabel(s), "Coming soon") {
|
||||
t.Fatalf("disabled label: %q", SlotLabel(s))
|
||||
}
|
||||
if s.ID == "xpilot" {
|
||||
t.Fatal("xpilot registered as adapter must not stay Coming soon")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasVPilot {
|
||||
t.Fatal("expected vpilot enabled")
|
||||
}
|
||||
if enabled < 1 || coming < 1 {
|
||||
if !hasXPilot {
|
||||
t.Fatal("expected xpilot enabled (adapter registered)")
|
||||
}
|
||||
if enabled < 2 || coming < 1 {
|
||||
t.Fatalf("enabled=%d coming=%d slots=%d", enabled, coming, len(slots))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
"github.com/renorris/openfsd/internal/clientinject/adapters"
|
||||
"github.com/renorris/openfsd/internal/clientinject/adapters/vpilot"
|
||||
"github.com/renorris/openfsd/internal/clientinject/adapters/xpilot"
|
||||
)
|
||||
|
||||
// Exit codes (design: docs/design/client-runtime-injector.md).
|
||||
@@ -35,7 +36,7 @@ const usageText = `openfsd-client — openfsd Client Setup (GUI + headless CLI)
|
||||
Usage:
|
||||
openfsd-client Launch GUI when a display is available; else this help
|
||||
openfsd-client list-profiles
|
||||
openfsd-client detect --client vpilot
|
||||
openfsd-client detect --client vpilot|xpilot
|
||||
openfsd-client plan|apply|revert|health|launch [flags]
|
||||
|
||||
Shared flags for plan|apply|health|launch:
|
||||
@@ -44,10 +45,10 @@ Shared flags for plan|apply|health|launch:
|
||||
--fsd-port PORT FSD TCP port (default 6809, omitted from server list when default)
|
||||
--fsd-server-name NAME CachedServers label (default OPENFSD)
|
||||
--afv-base URL AFV REST public base (optional)
|
||||
--force-disable-afv Always launch with -novoice
|
||||
--prefer-short-jwt Prefer short JWT paths (/j, /fsd-jwt, …) for #US budget
|
||||
--install DIR vPilot install directory (required when not auto-detected)
|
||||
--client ID Client adapter id (default vpilot)
|
||||
--force-disable-afv Always launch with -novoice (vPilot)
|
||||
--prefer-short-jwt Prefer short JWT paths (/j, /fsd-jwt, …) for #US budget (vPilot)
|
||||
--install DIR Client install directory (required when not auto-detected)
|
||||
--client ID Client adapter id (vpilot|xpilot; default vpilot)
|
||||
--profiles-dir DIR Optional override profile directory (YAML)
|
||||
|
||||
Launch-only flags:
|
||||
@@ -58,12 +59,14 @@ Launch-only flags:
|
||||
preserves relpath under temp (Phase 1 typically PE-only).
|
||||
|
||||
Readiness honesty:
|
||||
Default JWT path /api/v1/fsd-jwt allows max host 12 characters for in-place #US
|
||||
patch (budget 35 runes). Use --prefer-short-jwt for /j (max host 25) if the
|
||||
server has fixed short JWT routes (direct POST /j, not a 302 redirect).
|
||||
vPilot: default JWT path /api/v1/fsd-jwt allows max host 12 characters for
|
||||
in-place #US patch (budget 35 runes). Use --prefer-short-jwt for /j (max host
|
||||
25) if the server has fixed short JWT routes (direct POST /j, not a 302).
|
||||
Long hostnames without short paths or free-slot remap are plan blockers.
|
||||
AFV PE ret-disable is out of scope until research gate R2; over-budget AFV
|
||||
falls back to -novoice.
|
||||
xPilot 3.0.1: PE padded_string + LEA fixups for status.json + fsd-jwt; large
|
||||
slot budgets; AFV not retargeted; unknown PE hashes refused.
|
||||
|
||||
Exit codes:
|
||||
0 ok
|
||||
@@ -410,11 +413,23 @@ func buildInstall(eng *clientinject.Engine, clientID, root string) clientinject.
|
||||
switch clientID {
|
||||
case "vpilot":
|
||||
install = vpilot.InstallFromDir(root)
|
||||
case "xpilot":
|
||||
install = xpilot.InstallFromDir(root)
|
||||
default:
|
||||
// Fall back to profile primary binary name when available.
|
||||
primary := "client.exe"
|
||||
if eng != nil && eng.Profiles != nil {
|
||||
for _, p := range eng.Profiles.ForClient(clientID) {
|
||||
if p.PrimaryBinary.RelativePath != "" {
|
||||
primary = p.PrimaryBinary.RelativePath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
install = clientinject.Install{
|
||||
ClientID: clientID,
|
||||
RootDir: root,
|
||||
PrimaryPE: filepath.Join(root, "vPilot.exe"),
|
||||
PrimaryPE: filepath.Join(root, primary),
|
||||
}
|
||||
}
|
||||
seedInstallFromManifest(eng, &install)
|
||||
|
||||
@@ -39,6 +39,9 @@ func TestRun_ListProfiles(t *testing.T) {
|
||||
if !strings.Contains(out.String(), "vpilot-3.12.1") {
|
||||
t.Fatalf("expected embedded profile: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "xpilot-3.0.1") {
|
||||
t.Fatalf("expected xpilot profile: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_PlanUsageMissingInstall(t *testing.T) {
|
||||
|
||||
112
docs/client-injector/research/xpilot.md
Normal file
112
docs/client-injector/research/xpilot.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# xPilot 3.0.1 — reverse-engineering research notes
|
||||
|
||||
**Date:** 2026-07-28
|
||||
**Client:** xPilot 3.0.1 (`xPilot.exe`)
|
||||
**Legal posture:** Research notes + hashes only. **Never commit or redistribute** xPilot binaries. Users install from the official xPilot distribution.
|
||||
|
||||
Tracked profile: `third_party/client-profiles/xpilot-3.0.1.yaml`
|
||||
Embed plan profile: `internal/clientinject/profiles/xpilot-3.0.1.yaml`
|
||||
|
||||
## Fingerprints
|
||||
|
||||
| Artifact | SHA-1 |
|
||||
|----------|-------|
|
||||
| `xPilot.exe` (3.0.1) | `1ae61e1d4a624751124a49cd992c90f948c31d37` |
|
||||
|
||||
Default install path (prior art): `C:\Program Files\xPilot\xPilot.exe`.
|
||||
|
||||
**SHA-256 / installer size:** not recorded in prior art; leave blank until a maintainer re-hashes a user-owned install.
|
||||
|
||||
## Stack
|
||||
|
||||
- Native Windows PE64 (not CLR / not #US). Prior-art patches target `.text` / `.idata` / `.data` section VAs.
|
||||
- Built-in AFV voice path (separate from FSD). **Prior-art 3.0.1 patchfile does not retarget AFV** — openfsd AFV requires a future research pass or an external AFV client (TrackAudio, etc.).
|
||||
- Network surfaces of interest: status JSON URL, fsd-jwt URL, residual `fsd.vatsim.net` string.
|
||||
|
||||
## Connection surfaces (what the 3.0.1 prior-art path redirects)
|
||||
|
||||
| Surface | Stock role | openfsd target | Patch family |
|
||||
|---------|------------|----------------|--------------|
|
||||
| Network status | VATSIM status JSON | `https://{host}/api/v1/data/status.json` | padded_string (UTF-8) + LEA RIP + length imm |
|
||||
| FSD JWT auth | VATSIM fsd-jwt | `https://{host}/api/v1/fsd-jwt` | padded_string (UTF-16LE) + LEA RIP×2 + length imm×2 |
|
||||
| FSD auto host | `fsd.vatsim.net` (or related) | break / neutralise | raw_overwrite 3 bytes (`foo`) in `.idata` |
|
||||
| AFV REST | (not in prior art) | — | **out of scope** for 3.0.1 adapter |
|
||||
|
||||
Unlike vPilot, there is **no obfuscated config XML** in the 3.0.1 path: server list comes from the retargeted status JSON feed.
|
||||
|
||||
## Section map (3.0.1 prior art)
|
||||
|
||||
| Section | Raw file offset | Virtual start |
|
||||
|---------|-----------------|---------------|
|
||||
| `.text` | `0x400` | `0x140001000` |
|
||||
| `.idata` | `0x01A5A200` | `0x141A5B000` |
|
||||
| `.data` | `0x027F9000` | `0x1427FA000` |
|
||||
|
||||
File offset conversion (same as openfsd-client-patch-utility):
|
||||
|
||||
```text
|
||||
file_offset = section.raw_offset + (section_address_va - section.virtual_start)
|
||||
```
|
||||
|
||||
### Computed file offsets (ported)
|
||||
|
||||
| Logical site | Section | VA | File offset |
|
||||
|--------------|---------|----|-------------|
|
||||
| status.json LEA RIP | `.text` | `0x140028D0E` | `0x2810E` |
|
||||
| status.json length imm | `.text` | `0x140028D07` | `0x28107` |
|
||||
| fsd-jwt LEA RIP (site 1) | `.text` | `0x140035BFE` | `0x34FFE` |
|
||||
| fsd-jwt length (site 1) | `.text` | `0x140035C0A` | `0x3500A` |
|
||||
| fsd-jwt LEA RIP (site 2) | `.text` | `0x14006BE08` | `0x6B208` |
|
||||
| fsd-jwt length (site 2) | `.text` | `0x14006BE14` | `0x6B214` |
|
||||
| break `fsd.vatsim.net` | `.idata` | `0x141CBF240` | `0x1CBE440` |
|
||||
| status.json string slot | `.idata` | `0x141AAF5AE` | `0x1AAE7AE` |
|
||||
| fsd-jwt string slot | `.idata` | `0x141A9662C` | `0x1A9582C` |
|
||||
|
||||
### Slot budgets
|
||||
|
||||
| Slot | Encoding | `available_bytes` | Notes |
|
||||
|------|----------|-------------------|-------|
|
||||
| status.json URL | UTF-8 (+ NUL pad) | `0x3B5` (949) | Length imm is **single byte** (max URL length 255) |
|
||||
| fsd-jwt URL | UTF-16LE (+ U+0000 pad) | `0x3B4` (948) | Length imm is **character count**, single byte |
|
||||
|
||||
LEA RIP displacements in prior art point at the **relocated** `.idata` slots (not the stock string sites). They are **content-independent** for a fixed slot address. Length immediates **must** match the new URL length at Apply time.
|
||||
|
||||
Example prior-art URLs (length reference only):
|
||||
|
||||
- `https://yourfsdserver.com/api/v1/data/status.json` → length `49`
|
||||
- `https://yourfsdserver.com/api/v1/fsd-jwt` → length `40`
|
||||
|
||||
## Prior art source
|
||||
|
||||
| Repo / path | Role |
|
||||
|-------------|------|
|
||||
| `renorris/openfsd-client-patch-utility` `example_patchfiles/xpilot/xpilot-3.0.1.yaml` | Section overwrite + padded string patchfile for sum `1ae61e1d…` |
|
||||
| Same repo `patch/section_*.go` | VA→raw conversion + padded write semantics |
|
||||
| openfsd wiki `Client-Connection.md` | Points operators at that utility for xPilot |
|
||||
|
||||
**This PR ports those offsets into schema v2 + `internal/clientinject` adapter.** Offsets were **derived** from the published prior-art YAML (VA + section map → file offset). They were **not re-verified against a live `xPilot.exe` binary in this environment** (binaries are never committed). The adapter **refuses** any PE whose SHA-1 ≠ profile stock.
|
||||
|
||||
## Adapter strategy (openfsd Client Setup)
|
||||
|
||||
1. **Discover** — `Program Files\xPilot`, `Program Files (x86)\xPilot`, Wine equivalents.
|
||||
2. **Verify** — primary PE SHA-1 must match profile (or stock bak after re-apply).
|
||||
3. **Plan / Apply** — `padded_string` for status + JWT; `raw_overwrite` for LEA RIP fixups, dynamic length bytes, and break-fsd; transactional `.openfsd-bak` via engine.
|
||||
4. **HealthCheck** — decode padded slots; assert URLs match planned endpoints; assert break site is not stock prefix when patched.
|
||||
5. **LaunchArgs** — empty (no documented xPilot CLI server override in prior art).
|
||||
6. **AFV** — warn only; not patched. Operators may set AFV base in an external client or await a future profile.
|
||||
|
||||
## Honesty / known gaps
|
||||
|
||||
- [ ] Re-hash a maintainer-owned 3.0.1 install; add SHA-256 + `size_bytes` when confirmed.
|
||||
- [ ] Confirm live connect path after status.json + fsd-jwt retarget (server list fields xPilot expects).
|
||||
- [ ] Inventory AFV / voice base URL sites in the same PE (and companion DLLs if any).
|
||||
- [ ] Confirm single-byte length immediates for non-ASCII hosts (openfsd URLs are ASCII).
|
||||
- [ ] Antivirus / code-signing interaction when rewriting signed `xPilot.exe`.
|
||||
- [ ] Newer xPilot versions need **new** version-pinned profiles — do not reuse 3.0.1 offsets.
|
||||
|
||||
## Legal / product constraints
|
||||
|
||||
- Do not redistribute xPilot or derivative binaries.
|
||||
- Track **hashes + profiles + research** in git only.
|
||||
- Product framing: **private openfsd networks** the operator is authorized to use.
|
||||
- Prefer reversible Apply/Revert; never delete user model/aircraft data outside the PE/config targets.
|
||||
@@ -4,12 +4,14 @@ package adapters
|
||||
import (
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
"github.com/renorris/openfsd/internal/clientinject/adapters/vpilot"
|
||||
"github.com/renorris/openfsd/internal/clientinject/adapters/xpilot"
|
||||
)
|
||||
|
||||
// DefaultAdapters returns the built-in client adapters (vPilot first).
|
||||
// DefaultAdapters returns the built-in client adapters (vPilot first, then xPilot).
|
||||
func DefaultAdapters() []clientinject.Adapter {
|
||||
return []clientinject.Adapter{
|
||||
vpilot.New(),
|
||||
xpilot.New(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,19 @@ import (
|
||||
|
||||
func TestDefaultAdapters(t *testing.T) {
|
||||
as := DefaultAdapters()
|
||||
if len(as) == 0 {
|
||||
t.Fatal("empty")
|
||||
if len(as) < 2 {
|
||||
t.Fatalf("want ≥2 adapters, got %d", len(as))
|
||||
}
|
||||
if as[0].ClientID() != "vpilot" {
|
||||
t.Fatalf("got %q", as[0].ClientID())
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, a := range as {
|
||||
ids[a.ClientID()] = true
|
||||
}
|
||||
if !ids["vpilot"] || !ids["xpilot"] {
|
||||
t.Fatalf("ids=%v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEngine(t *testing.T) {
|
||||
@@ -22,7 +29,17 @@ func TestDefaultEngine(t *testing.T) {
|
||||
if eng.Adapters["vpilot"] == nil {
|
||||
t.Fatal("missing vpilot")
|
||||
}
|
||||
if eng.Adapters["xpilot"] == nil {
|
||||
t.Fatal("missing xpilot")
|
||||
}
|
||||
if _, ok := eng.Profiles.Get("vpilot-3.12.1"); !ok {
|
||||
t.Fatal("missing profile")
|
||||
t.Fatal("missing vpilot profile")
|
||||
}
|
||||
if _, ok := eng.Profiles.Get("xpilot-3.0.1"); !ok {
|
||||
t.Fatal("missing xpilot profile")
|
||||
}
|
||||
// Multi-client: registry is not vPilot-only.
|
||||
if len(eng.Adapters) < 2 {
|
||||
t.Fatalf("adapters=%d", len(eng.Adapters))
|
||||
}
|
||||
}
|
||||
|
||||
273
internal/clientinject/adapters/xpilot/adapter.go
Normal file
273
internal/clientinject/adapters/xpilot/adapter.go
Normal file
@@ -0,0 +1,273 @@
|
||||
// Package xpilot implements the clientinject.Adapter for xPilot 3.0.1.
|
||||
//
|
||||
// Strategy (prior art openfsd-client-patch-utility xpilot-3.0.1.yaml):
|
||||
// padded_string slots for status.json (UTF-8) and fsd-jwt (UTF-16LE),
|
||||
// raw_overwrite for LEA RIP fixups, dynamic length immediates, and a
|
||||
// break of residual fsd.vatsim.net. AFV retarget is out of scope until
|
||||
// research inventories voice base sites.
|
||||
//
|
||||
// Offsets are version-pinned by primary PE SHA-1; unknown hashes are refused.
|
||||
package xpilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
)
|
||||
|
||||
const (
|
||||
clientID = "xpilot"
|
||||
displayName = "xPilot"
|
||||
primaryName = "xPilot.exe"
|
||||
profileID = "xpilot-3.0.1"
|
||||
|
||||
// Single-byte length immediates in the 3.0.1 prior-art path.
|
||||
maxLengthImm = 255
|
||||
)
|
||||
|
||||
// Adapter is the xPilot client strategy.
|
||||
type Adapter struct {
|
||||
// Writer is used by HealthCheck / Discover when non-nil; default OSFileWriter.
|
||||
Writer clientinject.FileWriter
|
||||
// Profiles overrides the embedded profile store (tests / --profiles-dir).
|
||||
Profiles *clientinject.ProfileStore
|
||||
}
|
||||
|
||||
// New returns an xPilot adapter.
|
||||
func New() *Adapter {
|
||||
return &Adapter{}
|
||||
}
|
||||
|
||||
// NewWithProfiles returns an adapter that resolves profiles from store.
|
||||
func NewWithProfiles(store *clientinject.ProfileStore) *Adapter {
|
||||
return &Adapter{Profiles: store}
|
||||
}
|
||||
|
||||
// ClientID implements clientinject.Adapter.
|
||||
func (a *Adapter) ClientID() string { return clientID }
|
||||
|
||||
// DisplayName implements clientinject.Adapter.
|
||||
func (a *Adapter) DisplayName() string { return displayName }
|
||||
|
||||
// SupportedProfiles implements clientinject.Adapter.
|
||||
func (a *Adapter) SupportedProfiles() []string {
|
||||
return []string{profileID}
|
||||
}
|
||||
|
||||
func (a *Adapter) writer() clientinject.FileWriter {
|
||||
if a != nil && a.Writer != nil {
|
||||
return a.Writer
|
||||
}
|
||||
return clientinject.OSFileWriter{}
|
||||
}
|
||||
|
||||
// Discover looks for xPilot installs (Program Files + Wine prefixes).
|
||||
func (a *Adapter) Discover(ctx context.Context) ([]clientinject.InstallCandidate, error) {
|
||||
_ = ctx
|
||||
var cands []clientinject.InstallCandidate
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
add := func(root, hint string) {
|
||||
root = filepath.Clean(root)
|
||||
if root == "" || root == "." {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[root]; ok {
|
||||
return
|
||||
}
|
||||
pe := filepath.Join(root, primaryName)
|
||||
if _, err := a.writer().Stat(pe); err != nil {
|
||||
return
|
||||
}
|
||||
seen[root] = struct{}{}
|
||||
cands = append(cands, clientinject.InstallCandidate{
|
||||
ClientID: clientID,
|
||||
RootDir: root,
|
||||
PrimaryPE: pe,
|
||||
ConfigPaths: nil,
|
||||
DisplayHint: hint,
|
||||
})
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
|
||||
if base := os.Getenv(env); base != "" {
|
||||
add(filepath.Join(base, "xPilot"), env+" install")
|
||||
}
|
||||
}
|
||||
// Some installs may live under LocalAppData (defensive).
|
||||
if local := os.Getenv("LOCALAPPDATA"); local != "" {
|
||||
add(filepath.Join(local, "xPilot"), "LocalAppData install")
|
||||
}
|
||||
default:
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = os.Getenv("USERNAME")
|
||||
}
|
||||
wineRoots := []string{
|
||||
filepath.Join(home, ".wine", "drive_c", "Program Files", "xPilot"),
|
||||
filepath.Join(home, ".wine", "drive_c", "Program Files (x86)", "xPilot"),
|
||||
}
|
||||
if user != "" {
|
||||
wineRoots = append(wineRoots,
|
||||
filepath.Join(home, ".wine", "drive_c", "users", user, "AppData", "Local", "xPilot"),
|
||||
)
|
||||
}
|
||||
for _, r := range wineRoots {
|
||||
add(r, "Wine install")
|
||||
}
|
||||
}
|
||||
}
|
||||
return cands, nil
|
||||
}
|
||||
|
||||
// Verify checks primary PE SHA-1 against the profile (stock bak allowed post-Apply).
|
||||
func (a *Adapter) Verify(install clientinject.Install, profile *clientinject.Profile) error {
|
||||
if profile == nil {
|
||||
return fmt.Errorf("xpilot: nil profile")
|
||||
}
|
||||
if profile.ClientID != clientID {
|
||||
return fmt.Errorf("xpilot: profile client_id %q is not xpilot", profile.ClientID)
|
||||
}
|
||||
pe := clientinject.AbsPrimaryPE(install)
|
||||
if pe == "" {
|
||||
return fmt.Errorf("xpilot: empty primary PE path")
|
||||
}
|
||||
w := a.writer()
|
||||
data, err := w.ReadFile(pe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpilot: read PE: %w", err)
|
||||
}
|
||||
sum := sha1.Sum(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
want := strings.ToLower(strings.TrimSpace(profile.PrimaryBinary.SHA1))
|
||||
if want == "" {
|
||||
return fmt.Errorf("xpilot: profile has no primary_binary.sha1")
|
||||
}
|
||||
if !strings.EqualFold(got, want) {
|
||||
bak := clientinject.BackupPath(pe)
|
||||
if _, bakErr := w.Stat(bak); bakErr == nil {
|
||||
if install.HashSHA1 != "" && strings.EqualFold(install.HashSHA1, want) {
|
||||
return nil
|
||||
}
|
||||
if bakData, rerr := w.ReadFile(bak); rerr == nil {
|
||||
bakSum := sha1.Sum(bakData)
|
||||
if strings.EqualFold(hex.EncodeToString(bakSum[:]), want) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("xpilot: PE sha1 %s does not match profile %s (stock %s); refuse unknown hash",
|
||||
got, profile.ProfileID, want)
|
||||
}
|
||||
if wantSize := profile.PrimaryBinary.SizeBytes; wantSize > 0 && int64(len(data)) != wantSize {
|
||||
return fmt.Errorf("xpilot: PE size %d does not match profile %d", len(data), wantSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndpointConstraints returns slot budget hints from the profile.
|
||||
func (a *Adapter) EndpointConstraints(profile *clientinject.Profile) []clientinject.Constraint {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
var out []clientinject.Constraint
|
||||
if s, ok := profile.Strings["status_json"]; ok && s.PayloadBudgetBytes > 0 {
|
||||
// UTF-8: budget is byte slot; max URL runes ≈ budget-1 (NUL).
|
||||
maxRunes := s.PayloadBudgetBytes - 1
|
||||
if maxRunes > maxLengthImm {
|
||||
maxRunes = maxLengthImm
|
||||
}
|
||||
out = append(out, clientinject.Constraint{
|
||||
Field: "StatusJSONURL",
|
||||
MaxRunes: maxRunes,
|
||||
Strategy: "padded_string",
|
||||
Description: fmt.Sprintf("UTF-8 padded slot %d bytes; length imm is 1 byte (max %d)", s.PayloadBudgetBytes, maxLengthImm),
|
||||
})
|
||||
}
|
||||
if s, ok := profile.Strings["fsd_jwt"]; ok && s.PayloadBudgetBytes > 0 {
|
||||
// UTF-16LE: encoded (runes+NUL)*2 must fit slot; length imm is char count ≤255.
|
||||
maxRunes := (s.PayloadBudgetBytes / 2) - 1
|
||||
if maxRunes > maxLengthImm {
|
||||
maxRunes = maxLengthImm
|
||||
}
|
||||
out = append(out, clientinject.Constraint{
|
||||
Field: "JWTURL",
|
||||
MaxRunes: maxRunes,
|
||||
Strategy: "padded_string",
|
||||
Description: fmt.Sprintf("UTF-16LE padded slot %d bytes (~%d runes); length imm max %d", s.PayloadBudgetBytes, maxRunes, maxLengthImm),
|
||||
})
|
||||
}
|
||||
out = append(out, clientinject.Constraint{
|
||||
Field: "AFVBaseURL",
|
||||
MaxRunes: 0,
|
||||
Strategy: "n/a",
|
||||
Description: "xPilot 3.0.1 prior art does not retarget AFV; configure voice separately or await a future profile",
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// LaunchArgs returns CLI flags for launching the client (none known for 3.0.1).
|
||||
func (a *Adapter) LaunchArgs(install clientinject.Install, ep clientinject.Endpoints) []string {
|
||||
_ = install
|
||||
_ = ep
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallFromDir builds an Install for an explicit --install directory.
|
||||
func InstallFromDir(root string) clientinject.Install {
|
||||
return New().InstallFromDir(root)
|
||||
}
|
||||
|
||||
// InstallFromDir builds an Install using this adapter's FileWriter.
|
||||
func (a *Adapter) InstallFromDir(root string) clientinject.Install {
|
||||
root = filepath.Clean(root)
|
||||
return clientinject.Install{
|
||||
ClientID: clientID,
|
||||
RootDir: root,
|
||||
PrimaryPE: filepath.Join(root, primaryName),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) loadProfileForInstall(install clientinject.Install) (*clientinject.Profile, error) {
|
||||
var store *clientinject.ProfileStore
|
||||
if a != nil && a.Profiles != nil {
|
||||
store = a.Profiles
|
||||
} else {
|
||||
var err error
|
||||
store, err = clientinject.LoadEmbedded()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if install.ProfileID != "" {
|
||||
if p, ok := store.Get(install.ProfileID); ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if install.HashSHA1 != "" {
|
||||
if p, ok := store.LookupByHash(install.ClientID, install.HashSHA1); ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
list := store.ForClient(clientID)
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("xpilot: no xpilot profiles loaded")
|
||||
}
|
||||
for _, p := range list {
|
||||
if strings.HasPrefix(p.ProfileID, "xpilot") {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
384
internal/clientinject/adapters/xpilot/adapter_test.go
Normal file
384
internal/clientinject/adapters/xpilot/adapter_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package xpilot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
"github.com/renorris/openfsd/internal/clientinject/pepatch"
|
||||
)
|
||||
|
||||
func sha1hex(b []byte) string {
|
||||
s := sha1.Sum(b)
|
||||
return hex.EncodeToString(s[:])
|
||||
}
|
||||
|
||||
// Compact synthetic layout — not production offsets (those need ~30MB PE).
|
||||
const (
|
||||
testStatusOff = 0x100
|
||||
testJWTOff = 0x200
|
||||
testStatusLEA = 0x50
|
||||
testStatusLen = 0x54
|
||||
testJWTLEA = 0x58
|
||||
testJWTLen = 0x5C
|
||||
testBreakOff = 0x60
|
||||
testStatusSlot = 64
|
||||
testJWTSlot = 80
|
||||
)
|
||||
|
||||
func fo(v int64) *clientinject.FlexibleInt64 {
|
||||
f := clientinject.FlexibleInt64(v)
|
||||
return &f
|
||||
}
|
||||
|
||||
func testProfile(pe []byte) *clientinject.Profile {
|
||||
return &clientinject.Profile{
|
||||
SchemaVersion: 2,
|
||||
ProfileID: "xpilot-test-synth",
|
||||
ClientID: clientID,
|
||||
DisplayName: "xPilot",
|
||||
SupportedClientVersion: "3.0.1-test",
|
||||
PrimaryBinary: clientinject.PrimaryBinarySpec{
|
||||
RelativePath: primaryName,
|
||||
SHA1: sha1hex(pe),
|
||||
SizeBytes: int64(len(pe)),
|
||||
},
|
||||
Strings: map[string]clientinject.StringSpec{
|
||||
"status_json": {PayloadBudgetBytes: testStatusSlot, Template: "{{.StatusJSONURL}}"},
|
||||
"fsd_jwt": {PayloadBudgetBytes: testJWTSlot, Template: "{{.JWTURL}}"},
|
||||
},
|
||||
Mutations: []clientinject.ProfileMutationSpec{
|
||||
{
|
||||
ID: "write_status_json", Kind: "padded_string", Description: "status",
|
||||
FileOffset: fo(testStatusOff), AvailableBytes: fo(testStatusSlot),
|
||||
Encoding: "utf8", EndpointKey: "status_json",
|
||||
},
|
||||
{
|
||||
ID: "write_fsd_jwt", Kind: "padded_string", Description: "jwt",
|
||||
FileOffset: fo(testJWTOff), AvailableBytes: fo(testJWTSlot),
|
||||
Encoding: "utf16le", EndpointKey: "fsd_jwt",
|
||||
},
|
||||
{
|
||||
ID: "patch_status_lea", Kind: "raw_overwrite", Description: "status lea",
|
||||
FileOffset: fo(testStatusLEA), NewBytes: []byte{0x9C, 0x68, 0xA8, 0x01},
|
||||
},
|
||||
{
|
||||
ID: "patch_status_len", Kind: "raw_overwrite", Description: "status len",
|
||||
FileOffset: fo(testStatusLen), LengthOf: "status_json",
|
||||
},
|
||||
{
|
||||
ID: "patch_jwt_lea", Kind: "raw_overwrite", Description: "jwt lea",
|
||||
FileOffset: fo(testJWTLEA), NewBytes: []byte{0x2A, 0x0A, 0xA6, 0x01},
|
||||
},
|
||||
{
|
||||
ID: "patch_jwt_len", Kind: "raw_overwrite", Description: "jwt len",
|
||||
FileOffset: fo(testJWTLen), LengthOf: "fsd_jwt",
|
||||
},
|
||||
{
|
||||
ID: "break_fsd", Kind: "raw_overwrite", Description: "break fsd",
|
||||
FileOffset: fo(testBreakOff), NewBytes: []byte{0x66, 0x6F, 0x6F},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildSynthPE(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
// Large enough for JWT slot end.
|
||||
size := testJWTOff + testJWTSlot + 16
|
||||
data := make([]byte, size)
|
||||
// Stock-like placeholders.
|
||||
copy(data[testStatusOff:], []byte("https://status.vatsim.net/old.json"))
|
||||
u := utf16.Encode([]rune("https://auth.vatsim.net/api/fsd-jwt"))
|
||||
for i, c := range u {
|
||||
binary.LittleEndian.PutUint16(data[testJWTOff+i*2:], c)
|
||||
}
|
||||
// break site stock-ish
|
||||
copy(data[testBreakOff:], []byte("fsd.vatsim.net"))
|
||||
return data
|
||||
}
|
||||
|
||||
func setupInstall(t *testing.T) (root string, pe []byte, install clientinject.Install, profile *clientinject.Profile, a *Adapter) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
pe = buildSynthPE(t)
|
||||
pePath := filepath.Join(root, primaryName)
|
||||
if err := os.WriteFile(pePath, pe, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile = testProfile(pe)
|
||||
store := clientinject.NewProfileStore()
|
||||
if err := store.Add(profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a = NewWithProfiles(store)
|
||||
install = clientinject.Install{
|
||||
ClientID: clientID,
|
||||
RootDir: root,
|
||||
PrimaryPE: pePath,
|
||||
HashSHA1: profile.PrimaryBinary.SHA1,
|
||||
ProfileID: profile.ProfileID,
|
||||
}
|
||||
return root, pe, install, profile, a
|
||||
}
|
||||
|
||||
func TestClientIDDisplay(t *testing.T) {
|
||||
a := New()
|
||||
if a.ClientID() != "xpilot" || a.DisplayName() != "xPilot" {
|
||||
t.Fatal(a.ClientID(), a.DisplayName())
|
||||
}
|
||||
if len(a.SupportedProfiles()) != 1 || a.SupportedProfiles()[0] != profileID {
|
||||
t.Fatalf("profiles=%v", a.SupportedProfiles())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_OK(t *testing.T) {
|
||||
_, _, install, profile, a := setupInstall(t)
|
||||
ep := clientinject.Endpoints{
|
||||
WebBaseURL: "https://fsd.ex.co",
|
||||
FSDHost: "fsd.ex.co",
|
||||
}
|
||||
plan, err := a.Plan(install, profile, ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Blockers) != 0 {
|
||||
t.Fatalf("blockers: %v", plan.Blockers)
|
||||
}
|
||||
var padded, raw int
|
||||
for _, m := range plan.Mutations {
|
||||
switch m.Kind {
|
||||
case clientinject.MutPaddedString:
|
||||
padded++
|
||||
case clientinject.MutRawOverwrite:
|
||||
raw++
|
||||
}
|
||||
}
|
||||
if padded != 2 {
|
||||
t.Fatalf("padded=%d", padded)
|
||||
}
|
||||
if raw < 4 {
|
||||
t.Fatalf("raw=%d", raw)
|
||||
}
|
||||
// Dynamic length for status URL.
|
||||
statusURL := ep.StatusJSONURL()
|
||||
var sawLen bool
|
||||
for _, m := range plan.Mutations {
|
||||
if m.ID != "patch_status_len" {
|
||||
continue
|
||||
}
|
||||
d, ok := rawDetail(m.Detail)
|
||||
if !ok || len(d.NewBytes) != 1 {
|
||||
t.Fatalf("detail=%+v", m.Detail)
|
||||
}
|
||||
if d.NewBytes[0] != byte(len([]rune(statusURL))) {
|
||||
t.Fatalf("len byte=%d want %d", d.NewBytes[0], len([]rune(statusURL)))
|
||||
}
|
||||
sawLen = true
|
||||
}
|
||||
if !sawLen {
|
||||
t.Fatal("missing status len mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_MissingWebBaseBlocker(t *testing.T) {
|
||||
_, _, install, profile, a := setupInstall(t)
|
||||
plan, err := a.Plan(install, profile, clientinject.Endpoints{FSDHost: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Blockers) == 0 {
|
||||
t.Fatal("expected blockers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_RefuseUnknownMutationKind(t *testing.T) {
|
||||
_, _, install, profile, a := setupInstall(t)
|
||||
profile.Mutations = append(profile.Mutations, clientinject.ProfileMutationSpec{
|
||||
ID: "bad", Kind: "cil_us_string", FileOffset: fo(1),
|
||||
})
|
||||
plan, err := a.Plan(install, profile, clientinject.Endpoints{
|
||||
WebBaseURL: "https://fsd.ex.co",
|
||||
FSDHost: "fsd.ex.co",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range plan.Blockers {
|
||||
if strings.Contains(b, "unsupported kind") || strings.Contains(b, "cil_us_string") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("blockers=%v", plan.Blockers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_RefuseUnknownHash(t *testing.T) {
|
||||
_, _, install, profile, a := setupInstall(t)
|
||||
// Corrupt PE.
|
||||
if err := os.WriteFile(install.PrimaryPE, []byte("not-the-pe"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := a.Verify(install, profile)
|
||||
if err == nil || !strings.Contains(err.Error(), "refuse unknown hash") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHealth_RoundTrip(t *testing.T) {
|
||||
_, stock, install, profile, a := setupInstall(t)
|
||||
ep := clientinject.Endpoints{
|
||||
WebBaseURL: "https://fsd.ex.co",
|
||||
FSDHost: "fsd.ex.co",
|
||||
}
|
||||
plan, err := a.Plan(install, profile, ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Blockers) != 0 {
|
||||
t.Fatalf("blockers: %v", plan.Blockers)
|
||||
}
|
||||
w := clientinject.OSFileWriter{}
|
||||
if err := a.Apply(context.Background(), plan, w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.HealthCheck(install, ep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ensure stock was actually changed.
|
||||
got, err := os.ReadFile(install.PrimaryPE)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(got, stock) {
|
||||
t.Fatal("PE unchanged after apply")
|
||||
}
|
||||
// Status URL present as UTF-8.
|
||||
status := ep.StatusJSONURL()
|
||||
if !bytes.Contains(got[testStatusOff:testStatusOff+testStatusSlot], []byte(status)) {
|
||||
t.Fatalf("status URL missing in slot")
|
||||
}
|
||||
// JWT as UTF-16LE.
|
||||
u := utf16.Encode([]rune(ep.JWTURL()))
|
||||
pat := make([]byte, len(u)*2)
|
||||
for i, c := range u {
|
||||
binary.LittleEndian.PutUint16(pat[i*2:], c)
|
||||
}
|
||||
if !bytes.Contains(got[testJWTOff:testJWTOff+testJWTSlot], pat) {
|
||||
t.Fatal("jwt utf16 missing")
|
||||
}
|
||||
// Break site.
|
||||
if !bytes.Equal(got[testBreakOff:testBreakOff+3], []byte("foo")) {
|
||||
t.Fatalf("break=%q", got[testBreakOff:testBreakOff+3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_ApplyRevert_WithXPilot(t *testing.T) {
|
||||
root, stock, install, profile, a := setupInstall(t)
|
||||
store := clientinject.NewProfileStore()
|
||||
if err := store.Add(profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng := clientinject.NewEngine(store, a)
|
||||
ep := clientinject.Endpoints{
|
||||
WebBaseURL: "https://fsd.ex.co",
|
||||
FSDHost: "fsd.ex.co",
|
||||
}
|
||||
plan, err := eng.Plan(context.Background(), install, ep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Blockers) != 0 {
|
||||
t.Fatalf("blockers: %v", plan.Blockers)
|
||||
}
|
||||
res, err := eng.Apply(context.Background(), plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.ManifestPath == "" {
|
||||
t.Fatal("empty manifest")
|
||||
}
|
||||
// Health via engine already ran inside Apply.
|
||||
got, _ := os.ReadFile(install.PrimaryPE)
|
||||
if bytes.Equal(got, stock) {
|
||||
t.Fatal("not patched")
|
||||
}
|
||||
if err := eng.Revert(context.Background(), root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, _ := os.ReadFile(install.PrimaryPE)
|
||||
if !bytes.Equal(restored, stock) {
|
||||
t.Fatal("revert did not restore stock PE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFromDir(t *testing.T) {
|
||||
inst := InstallFromDir("/tmp/xpilot-install")
|
||||
if inst.ClientID != clientID {
|
||||
t.Fatal(inst.ClientID)
|
||||
}
|
||||
if !strings.HasSuffix(inst.PrimaryPE, primaryName) {
|
||||
t.Fatal(inst.PrimaryPE)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_EmptyOnUnixWithoutWine(t *testing.T) {
|
||||
// Should not error; may return empty.
|
||||
cands, err := New().Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = cands
|
||||
}
|
||||
|
||||
func TestPaddedString_pepatchEncodingParity(t *testing.T) {
|
||||
// Sanity: pepatch accepts encodings used by profile.
|
||||
var buf struct {
|
||||
data []byte
|
||||
pos int64
|
||||
}
|
||||
buf.data = make([]byte, 32)
|
||||
// Use a small WriteSeeker via temp file.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "p.bin")
|
||||
if err := os.WriteFile(path, make([]byte, 64), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := pepatch.PaddedStringOverwrite(f, 0, "https://x.test/j", 32, "utf8"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// UTF-16LE needs (runes+1)*2 bytes; use a short string that fits remaining 32.
|
||||
if err := pepatch.PaddedStringOverwrite(f, 32, "https://x.t/j", 32, "utf16le"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointConstraints(t *testing.T) {
|
||||
_, _, _, profile, a := setupInstall(t)
|
||||
cs := a.EndpointConstraints(profile)
|
||||
if len(cs) < 2 {
|
||||
t.Fatalf("constraints=%v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchArgsEmpty(t *testing.T) {
|
||||
a := New()
|
||||
if args := a.LaunchArgs(clientinject.Install{}, clientinject.Endpoints{}); len(args) != 0 {
|
||||
t.Fatalf("args=%v", args)
|
||||
}
|
||||
}
|
||||
98
internal/clientinject/adapters/xpilot/apply.go
Normal file
98
internal/clientinject/adapters/xpilot/apply.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package xpilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
"github.com/renorris/openfsd/internal/clientinject/pepatch"
|
||||
)
|
||||
|
||||
// Apply writes plan mutations via FileWriter.
|
||||
func (a *Adapter) Apply(ctx context.Context, plan *clientinject.Plan, w clientinject.FileWriter) error {
|
||||
_ = ctx
|
||||
if plan == nil {
|
||||
return fmt.Errorf("xpilot: nil plan")
|
||||
}
|
||||
if w == nil {
|
||||
return fmt.Errorf("xpilot: nil FileWriter")
|
||||
}
|
||||
pePath := clientinject.AbsPrimaryPE(plan.Install)
|
||||
if pePath == "" {
|
||||
return fmt.Errorf("xpilot: PE mutation planned but PrimaryPE empty")
|
||||
}
|
||||
|
||||
needPE := false
|
||||
for _, m := range plan.Mutations {
|
||||
switch m.Kind {
|
||||
case clientinject.MutPaddedString, clientinject.MutRawOverwrite:
|
||||
needPE = true
|
||||
}
|
||||
}
|
||||
if !needPE {
|
||||
return nil
|
||||
}
|
||||
|
||||
peFile, err := w.OpenReadWrite(pePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpilot: open PE: %w", err)
|
||||
}
|
||||
defer peFile.Close()
|
||||
|
||||
for _, m := range plan.Mutations {
|
||||
switch m.Kind {
|
||||
case clientinject.MutPaddedString:
|
||||
d, ok := paddedDetail(m.Detail)
|
||||
if !ok {
|
||||
return fmt.Errorf("xpilot: mutation %s: bad PaddedStringDetail", m.ID)
|
||||
}
|
||||
if err := pepatch.PaddedStringOverwrite(peFile, d.FileOffset, d.NewString, d.SlotLen, d.Encoding); err != nil {
|
||||
return fmt.Errorf("xpilot: %s: %w", m.ID, err)
|
||||
}
|
||||
case clientinject.MutRawOverwrite:
|
||||
d, ok := rawDetail(m.Detail)
|
||||
if !ok {
|
||||
return fmt.Errorf("xpilot: mutation %s: bad RawOverwriteDetail", m.ID)
|
||||
}
|
||||
if len(d.NewBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := pepatch.OverwriteAt(peFile, d.FileOffset, d.NewBytes); err != nil {
|
||||
return fmt.Errorf("xpilot: %s: %w", m.ID, err)
|
||||
}
|
||||
case clientinject.MutLaunchFlag:
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("xpilot: unsupported mutation kind %q (id=%s)", m.Kind, m.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func paddedDetail(d any) (clientinject.PaddedStringDetail, bool) {
|
||||
switch v := d.(type) {
|
||||
case clientinject.PaddedStringDetail:
|
||||
return v, true
|
||||
case *clientinject.PaddedStringDetail:
|
||||
if v == nil {
|
||||
return clientinject.PaddedStringDetail{}, false
|
||||
}
|
||||
return *v, true
|
||||
default:
|
||||
return clientinject.PaddedStringDetail{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func rawDetail(d any) (clientinject.RawOverwriteDetail, bool) {
|
||||
switch v := d.(type) {
|
||||
case clientinject.RawOverwriteDetail:
|
||||
return v, true
|
||||
case *clientinject.RawOverwriteDetail:
|
||||
if v == nil {
|
||||
return clientinject.RawOverwriteDetail{}, false
|
||||
}
|
||||
return *v, true
|
||||
default:
|
||||
return clientinject.RawOverwriteDetail{}, false
|
||||
}
|
||||
}
|
||||
133
internal/clientinject/adapters/xpilot/health.go
Normal file
133
internal/clientinject/adapters/xpilot/health.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package xpilot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
)
|
||||
|
||||
// HealthCheck validates padded-string slots and raw length/break sites after Apply.
|
||||
func (a *Adapter) HealthCheck(install clientinject.Install, ep clientinject.Endpoints) error {
|
||||
ep = ep.Normalize()
|
||||
w := a.writer()
|
||||
pe := clientinject.AbsPrimaryPE(install)
|
||||
if pe == "" {
|
||||
return fmt.Errorf("xpilot: healthcheck: empty PrimaryPE")
|
||||
}
|
||||
data, err := w.ReadFile(pe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpilot: healthcheck read PE: %w", err)
|
||||
}
|
||||
|
||||
profile, err := a.loadProfileForInstall(install)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpilot: healthcheck profile: %w", err)
|
||||
}
|
||||
|
||||
statusURL := ep.StatusJSONURL()
|
||||
jwtURL := ep.JWTURL()
|
||||
endpoints := map[string]string{
|
||||
"status_json": statusURL,
|
||||
"fsd_jwt": jwtURL,
|
||||
"status": statusURL,
|
||||
}
|
||||
|
||||
for _, m := range profile.Mutations {
|
||||
if m.FileOffset == nil {
|
||||
continue
|
||||
}
|
||||
off := m.FileOffset.Int64()
|
||||
kind, err := clientinject.MapYAMLKind(m.Kind)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch kind {
|
||||
case clientinject.MutPaddedString:
|
||||
key := strings.TrimSpace(m.EndpointKey)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(m.StringRef)
|
||||
}
|
||||
want := endpoints[key]
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
slotLen := 0
|
||||
if m.AvailableBytes != nil {
|
||||
slotLen = int(m.AvailableBytes.Int64())
|
||||
}
|
||||
if slotLen <= 0 {
|
||||
if s, ok := profile.Strings[key]; ok {
|
||||
slotLen = s.PayloadBudgetBytes
|
||||
}
|
||||
}
|
||||
if slotLen <= 0 || off < 0 || int(off)+slotLen > len(data) {
|
||||
return fmt.Errorf("xpilot: healthcheck: padded slot %s @%#x out of range", m.ID, off)
|
||||
}
|
||||
got, err := decodePadded(data[off:int(off)+slotLen], m.Encoding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpilot: healthcheck %s: %w", m.ID, err)
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("xpilot: healthcheck %s @%#x = %q, want %q", m.ID, off, got, want)
|
||||
}
|
||||
case clientinject.MutRawOverwrite:
|
||||
if lo := strings.TrimSpace(m.LengthOf); lo != "" {
|
||||
url := endpoints[lo]
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
want := byte(len([]rune(url)))
|
||||
if off < 0 || int(off) >= len(data) {
|
||||
return fmt.Errorf("xpilot: healthcheck: length site %s out of range", m.ID)
|
||||
}
|
||||
if data[off] != want {
|
||||
return fmt.Errorf("xpilot: healthcheck %s @%#x = %d, want %d", m.ID, off, data[off], want)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(m.NewBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
if off < 0 || int(off)+len(m.NewBytes) > len(data) {
|
||||
return fmt.Errorf("xpilot: healthcheck: raw site %s out of range", m.ID)
|
||||
}
|
||||
got := data[off : int(off)+len(m.NewBytes)]
|
||||
if !bytes.Equal(got, m.NewBytes) {
|
||||
return fmt.Errorf("xpilot: healthcheck %s @%#x = %x, want %x", m.ID, off, got, m.NewBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePadded(slot []byte, encoding string) (string, error) {
|
||||
enc := strings.ToLower(strings.TrimSpace(encoding))
|
||||
switch enc {
|
||||
case "utf16le", "utf-16le", "utf16":
|
||||
if len(slot) < 2 {
|
||||
return "", fmt.Errorf("utf16 slot too short")
|
||||
}
|
||||
// Read until U+0000 or end.
|
||||
nUnits := len(slot) / 2
|
||||
units := make([]uint16, 0, nUnits)
|
||||
for i := 0; i < nUnits; i++ {
|
||||
u := binary.LittleEndian.Uint16(slot[i*2:])
|
||||
if u == 0 {
|
||||
break
|
||||
}
|
||||
units = append(units, u)
|
||||
}
|
||||
return string(utf16.Decode(units)), nil
|
||||
default:
|
||||
// utf8/ascii: until first 0x00.
|
||||
i := bytes.IndexByte(slot, 0)
|
||||
if i < 0 {
|
||||
return string(slot), nil
|
||||
}
|
||||
return string(slot[:i]), nil
|
||||
}
|
||||
}
|
||||
223
internal/clientinject/adapters/xpilot/plan.go
Normal file
223
internal/clientinject/adapters/xpilot/plan.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package xpilot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/renorris/openfsd/internal/clientinject"
|
||||
)
|
||||
|
||||
// Plan builds a dry-run mutation plan from the profile. Does not write disk.
|
||||
func (a *Adapter) Plan(install clientinject.Install, profile *clientinject.Profile, ep clientinject.Endpoints) (*clientinject.Plan, error) {
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("xpilot: nil profile")
|
||||
}
|
||||
ep = ep.Normalize()
|
||||
plan := &clientinject.Plan{
|
||||
Install: install,
|
||||
Endpoints: ep,
|
||||
Constraints: a.EndpointConstraints(profile),
|
||||
}
|
||||
|
||||
if ep.WebBaseURL == "" {
|
||||
plan.Blockers = append(plan.Blockers, "WebBaseURL is required")
|
||||
}
|
||||
// FSDHost is not written into the PE (status.json feed supplies servers),
|
||||
// but keep the form contract: require it so operators configure a coherent pair.
|
||||
if ep.FSDHost == "" {
|
||||
plan.Blockers = append(plan.Blockers, "FSDHost is required (used with openfsd status feed / operator checklist)")
|
||||
}
|
||||
|
||||
statusURL := ep.StatusJSONURL()
|
||||
jwtURL := ep.JWTURL()
|
||||
// Prefer full /api/v1/fsd-jwt; PreferShortJWTPath still works via Endpoints.JWTURL.
|
||||
if ep.PreferShortJWTPath && jwtURL != "" {
|
||||
plan.Warnings = append(plan.Warnings,
|
||||
"PreferShortJWTPath set — ensure server has fixed short JWT routes; xPilot slots are large so default /api/v1/fsd-jwt usually fits")
|
||||
}
|
||||
|
||||
endpoints := map[string]string{
|
||||
"status_json": statusURL,
|
||||
"fsd_jwt": jwtURL,
|
||||
"status": statusURL,
|
||||
}
|
||||
|
||||
if statusURL == "" && ep.WebBaseURL != "" {
|
||||
plan.Blockers = append(plan.Blockers, "StatusJSONURL empty after normalize")
|
||||
}
|
||||
if jwtURL == "" && ep.WebBaseURL != "" {
|
||||
plan.Blockers = append(plan.Blockers, "JWTURL empty after normalize")
|
||||
}
|
||||
|
||||
// Validate endpoint lengths against slots + single-byte length imm.
|
||||
if statusURL != "" {
|
||||
if err := checkURLFits(statusURL, profile, "status_json", "utf8"); err != nil {
|
||||
plan.Blockers = append(plan.Blockers, err.Error())
|
||||
updateConstraintStrategy(plan, "StatusJSONURL", "blocker")
|
||||
}
|
||||
}
|
||||
if jwtURL != "" {
|
||||
if err := checkURLFits(jwtURL, profile, "fsd_jwt", "utf16le"); err != nil {
|
||||
plan.Blockers = append(plan.Blockers, err.Error())
|
||||
updateConstraintStrategy(plan, "JWTURL", "blocker")
|
||||
}
|
||||
}
|
||||
|
||||
if len(profile.Mutations) == 0 {
|
||||
plan.Blockers = append(plan.Blockers, "profile has no mutations (research incomplete)")
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
for _, m := range profile.Mutations {
|
||||
if m.FileOffset == nil {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: missing file_offset", m.ID))
|
||||
continue
|
||||
}
|
||||
off := m.FileOffset.Int64()
|
||||
if off <= 0 {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: invalid file_offset %d", m.ID, off))
|
||||
continue
|
||||
}
|
||||
kind, err := clientinject.MapYAMLKind(m.Kind)
|
||||
if err != nil {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: %v", m.ID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case clientinject.MutPaddedString:
|
||||
key := strings.TrimSpace(m.EndpointKey)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(m.StringRef)
|
||||
}
|
||||
url := endpoints[key]
|
||||
if url == "" {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: unknown/empty endpoint_key %q", m.ID, key))
|
||||
continue
|
||||
}
|
||||
slotLen := 0
|
||||
if m.AvailableBytes != nil {
|
||||
slotLen = int(m.AvailableBytes.Int64())
|
||||
}
|
||||
if slotLen <= 0 {
|
||||
if s, ok := profile.Strings[key]; ok && s.PayloadBudgetBytes > 0 {
|
||||
slotLen = s.PayloadBudgetBytes
|
||||
}
|
||||
}
|
||||
if slotLen <= 0 {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: missing available_bytes", m.ID))
|
||||
continue
|
||||
}
|
||||
enc := m.Encoding
|
||||
if enc == "" {
|
||||
enc = "utf8"
|
||||
}
|
||||
plan.Mutations = append(plan.Mutations, clientinject.Mutation{
|
||||
ID: m.ID,
|
||||
Kind: clientinject.MutPaddedString,
|
||||
Description: m.Description,
|
||||
TargetRel: profile.PrimaryBinary.RelativePath,
|
||||
Detail: clientinject.PaddedStringDetail{
|
||||
FileOffset: off,
|
||||
NewString: url,
|
||||
SlotLen: slotLen,
|
||||
Encoding: enc,
|
||||
},
|
||||
})
|
||||
|
||||
case clientinject.MutRawOverwrite:
|
||||
if lo := strings.TrimSpace(m.LengthOf); lo != "" {
|
||||
url := endpoints[lo]
|
||||
if url == "" {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: length_of %q empty", m.ID, lo))
|
||||
continue
|
||||
}
|
||||
n := utf8.RuneCountInString(url)
|
||||
if n > maxLengthImm {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: URL length %d exceeds single-byte imm max %d", m.ID, n, maxLengthImm))
|
||||
continue
|
||||
}
|
||||
plan.Mutations = append(plan.Mutations, clientinject.Mutation{
|
||||
ID: m.ID,
|
||||
Kind: clientinject.MutRawOverwrite,
|
||||
Description: m.Description,
|
||||
TargetRel: profile.PrimaryBinary.RelativePath,
|
||||
Detail: clientinject.RawOverwriteDetail{
|
||||
FileOffset: off,
|
||||
NewBytes: []byte{byte(n)},
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if len(m.NewBytes) == 0 {
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: raw_overwrite needs new_bytes or length_of", m.ID))
|
||||
continue
|
||||
}
|
||||
plan.Mutations = append(plan.Mutations, clientinject.Mutation{
|
||||
ID: m.ID,
|
||||
Kind: clientinject.MutRawOverwrite,
|
||||
Description: m.Description,
|
||||
TargetRel: profile.PrimaryBinary.RelativePath,
|
||||
Detail: clientinject.RawOverwriteDetail{
|
||||
FileOffset: off,
|
||||
NewBytes: append([]byte(nil), m.NewBytes...),
|
||||
},
|
||||
})
|
||||
|
||||
default:
|
||||
plan.Blockers = append(plan.Blockers, fmt.Sprintf("mutation %s: unsupported kind %q for xpilot adapter", m.ID, m.Kind))
|
||||
}
|
||||
}
|
||||
|
||||
if ep.AFVBaseURL != "" {
|
||||
plan.Warnings = append(plan.Warnings,
|
||||
"AFVBaseURL set but xPilot 3.0.1 adapter does not retarget voice; configure AFV outside this PE patch or use a future profile")
|
||||
}
|
||||
if ep.ForceDisableAFV {
|
||||
plan.Warnings = append(plan.Warnings,
|
||||
"ForceDisableAFV has no PE/launch effect for xPilot 3.0.1 (no -novoice equivalent in prior art)")
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func checkURLFits(url string, profile *clientinject.Profile, stringKey, encoding string) error {
|
||||
n := utf8.RuneCountInString(url)
|
||||
if n > maxLengthImm {
|
||||
return fmt.Errorf("%s URL length %d exceeds single-byte length immediate max %d", stringKey, n, maxLengthImm)
|
||||
}
|
||||
budget := 0
|
||||
if s, ok := profile.Strings[stringKey]; ok {
|
||||
budget = s.PayloadBudgetBytes
|
||||
}
|
||||
if budget <= 0 {
|
||||
return nil
|
||||
}
|
||||
enc := strings.ToLower(encoding)
|
||||
var need int
|
||||
switch enc {
|
||||
case "utf16le", "utf-16le", "utf16":
|
||||
need = (n + 1) * 2 // runes + NUL
|
||||
default:
|
||||
// utf8/ascii: bytes + NUL; openfsd URLs are ASCII so runes==bytes.
|
||||
need = len(url) + 1
|
||||
}
|
||||
if need > budget {
|
||||
return fmt.Errorf("%s URL needs %d encoded bytes > slot budget %d", stringKey, need, budget)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConstraintStrategy(plan *clientinject.Plan, field, strategy string) {
|
||||
for i := range plan.Constraints {
|
||||
if plan.Constraints[i].Field == field {
|
||||
plan.Constraints[i].Strategy = strategy
|
||||
return
|
||||
}
|
||||
}
|
||||
plan.Constraints = append(plan.Constraints, clientinject.Constraint{
|
||||
Field: field,
|
||||
Strategy: strategy,
|
||||
})
|
||||
}
|
||||
@@ -20,28 +20,37 @@ const RequiredSchemaVersion = 2
|
||||
|
||||
// Profile is a loaded schema_version 2 document (fingerprint + mutations).
|
||||
type Profile struct {
|
||||
SchemaVersion int `yaml:"schema_version"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Vendor string `yaml:"vendor"`
|
||||
LicenseNote string `yaml:"license_note"`
|
||||
ProfileVersion string `yaml:"profile_version"`
|
||||
SupportedClientVersion string `yaml:"supported_client_version"`
|
||||
Released string `yaml:"released"`
|
||||
DownloadPage string `yaml:"download_page"`
|
||||
Installer *InstallerSpec `yaml:"installer"`
|
||||
PrimaryBinary PrimaryBinarySpec `yaml:"primary_binary"`
|
||||
ConfigFiles []ConfigFileSpec `yaml:"config_files"`
|
||||
RelatedBinaries []string `yaml:"related_binaries"`
|
||||
CLR *CLRSpec `yaml:"clr"`
|
||||
Strings map[string]StringSpec `yaml:"strings"`
|
||||
USFreeSlots []USFreeSlot `yaml:"us_free_slots"`
|
||||
Mutations []ProfileMutationSpec `yaml:"mutations"`
|
||||
Launch LaunchSpec `yaml:"launch"`
|
||||
SchemaVersion int `yaml:"schema_version"`
|
||||
ClientID string `yaml:"client_id"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Vendor string `yaml:"vendor"`
|
||||
LicenseNote string `yaml:"license_note"`
|
||||
ProfileVersion string `yaml:"profile_version"`
|
||||
SupportedClientVersion string `yaml:"supported_client_version"`
|
||||
Released string `yaml:"released"`
|
||||
DownloadPage string `yaml:"download_page"`
|
||||
Installer *InstallerSpec `yaml:"installer"`
|
||||
PrimaryBinary PrimaryBinarySpec `yaml:"primary_binary"`
|
||||
ConfigFiles []ConfigFileSpec `yaml:"config_files"`
|
||||
RelatedBinaries []string `yaml:"related_binaries"`
|
||||
// PESections documents native PE section maps (xPilot-class VA→file conversion).
|
||||
PESections []PESectionSpec `yaml:"pe_sections"`
|
||||
CLR *CLRSpec `yaml:"clr"`
|
||||
Strings map[string]StringSpec `yaml:"strings"`
|
||||
USFreeSlots []USFreeSlot `yaml:"us_free_slots"`
|
||||
Mutations []ProfileMutationSpec `yaml:"mutations"`
|
||||
Launch LaunchSpec `yaml:"launch"`
|
||||
// ProfileID is the stem of the YAML file (e.g. "vpilot-3.12.1"), set by loader.
|
||||
ProfileID string `yaml:"-"`
|
||||
}
|
||||
|
||||
// PESectionSpec is a PE section raw/virtual base for VA→file offset conversion.
|
||||
type PESectionSpec struct {
|
||||
Name string `yaml:"name"`
|
||||
RawOffset FlexibleInt64 `yaml:"raw_offset"`
|
||||
VirtualStart FlexibleInt64 `yaml:"virtual_start"`
|
||||
}
|
||||
|
||||
// InstallerSpec describes the upstream installer (metadata only).
|
||||
type InstallerSpec struct {
|
||||
Filename string `yaml:"filename"`
|
||||
@@ -115,10 +124,33 @@ type ProfileMutationSpec struct {
|
||||
FileOffset *FlexibleInt64 `yaml:"file_offset"`
|
||||
NewBytes []byte `yaml:"new_bytes"`
|
||||
OnlyIf string `yaml:"only_if"`
|
||||
// AvailableBytes is the padded-string slot size (padded_string kind).
|
||||
AvailableBytes *FlexibleInt64 `yaml:"available_bytes"`
|
||||
// Encoding is "utf8" / "ascii" / "utf16le" for padded_string.
|
||||
Encoding string `yaml:"encoding"`
|
||||
// EndpointKey selects a planned URL: "status_json", "fsd_jwt", "status", "afv_base", …
|
||||
EndpointKey string `yaml:"endpoint_key"`
|
||||
// LengthOf, when set on raw_overwrite, writes a single-byte length of that endpoint URL.
|
||||
LengthOf string `yaml:"length_of"`
|
||||
// Fields is free-form for config_rewrite / vpilot_config.
|
||||
Fields map[string]any `yaml:"fields"`
|
||||
}
|
||||
|
||||
// FileOffsetOf returns the VA→file conversion for a section-relative virtual address,
|
||||
// or (-1, false) if the section is unknown.
|
||||
func (p *Profile) FileOffsetOf(sectionName string, virtualAddr int64) (int64, bool) {
|
||||
if p == nil {
|
||||
return -1, false
|
||||
}
|
||||
for _, sec := range p.PESections {
|
||||
if sec.Name != sectionName {
|
||||
continue
|
||||
}
|
||||
return sec.RawOffset.Int64() + (virtualAddr - sec.VirtualStart.Int64()), true
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
// LaunchSpec holds default launch flag templates.
|
||||
type LaunchSpec struct {
|
||||
ServerAddressFlag string `yaml:"server_address_flag"`
|
||||
|
||||
@@ -51,6 +51,45 @@ func TestLoadEmbedded(t *testing.T) {
|
||||
if len(list) < 1 {
|
||||
t.Fatal("ForClient empty")
|
||||
}
|
||||
|
||||
// xPilot 3.0.1 embed profile (second client).
|
||||
xp, ok := store.Get("xpilot-3.0.1")
|
||||
if !ok {
|
||||
t.Fatal("missing xpilot-3.0.1")
|
||||
}
|
||||
if xp.ClientID != "xpilot" {
|
||||
t.Fatalf("client=%q", xp.ClientID)
|
||||
}
|
||||
if xp.PrimaryBinary.SHA1 != "1ae61e1d4a624751124a49cd992c90f948c31d37" {
|
||||
t.Fatalf("xpilot sha1=%q", xp.PrimaryBinary.SHA1)
|
||||
}
|
||||
if len(xp.PESections) < 2 {
|
||||
t.Fatalf("pe_sections=%d", len(xp.PESections))
|
||||
}
|
||||
// status string VA → file offset from research notes.
|
||||
off, ok := xp.FileOffsetOf(".idata", 0x141AAF5AE)
|
||||
if !ok || off != 0x1AAE7AE {
|
||||
t.Fatalf("status file offset=%#x ok=%v", off, ok)
|
||||
}
|
||||
// Ensure padded_string mutations have file offsets.
|
||||
var sawPadded, sawLen bool
|
||||
for _, m := range xp.Mutations {
|
||||
if m.Kind == "padded_string" {
|
||||
sawPadded = true
|
||||
if m.FileOffset == nil || m.AvailableBytes == nil {
|
||||
t.Fatalf("padded mutation incomplete: %+v", m)
|
||||
}
|
||||
}
|
||||
if m.LengthOf != "" {
|
||||
sawLen = true
|
||||
}
|
||||
}
|
||||
if !sawPadded || !sawLen {
|
||||
t.Fatalf("padded=%v length_of=%v mutations=%d", sawPadded, sawLen, len(xp.Mutations))
|
||||
}
|
||||
if _, ok := store.LookupByHash("xpilot", "1AE61E1D4A624751124A49CD992C90F948C31D37"); !ok {
|
||||
t.Fatal("xpilot lookup by hash failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProfile_RejectsV1(t *testing.T) {
|
||||
|
||||
116
internal/clientinject/profiles/xpilot-3.0.1.yaml
Normal file
116
internal/clientinject/profiles/xpilot-3.0.1.yaml
Normal file
@@ -0,0 +1,116 @@
|
||||
# openfsd client profile — schema v2. Do NOT ship xPilot binaries.
|
||||
# Offsets from docs/client-injector/research/xpilot.md (prior-art VA→file conversion).
|
||||
# Version-pinned by primary PE SHA-1; refuse unknown hashes.
|
||||
|
||||
schema_version: 2
|
||||
client_id: xpilot
|
||||
display_name: xPilot
|
||||
vendor: Justin Shannon / xPilot project
|
||||
license_note: Proprietary; not redistributable by openfsd
|
||||
profile_version: "2"
|
||||
supported_client_version: "3.0.1"
|
||||
download_page: https://github.com/xpilot-project/xpilot/releases
|
||||
|
||||
primary_binary:
|
||||
relative_path: xPilot.exe
|
||||
default_install_globs:
|
||||
windows:
|
||||
- "%ProgramFiles%\\xPilot\\xPilot.exe"
|
||||
- "%ProgramFiles(x86)%\\xPilot\\xPilot.exe"
|
||||
sha1: 1ae61e1d4a624751124a49cd992c90f948c31d37
|
||||
pe:
|
||||
clr: false
|
||||
architecture: x86_64
|
||||
|
||||
# No client-local config rewrite in the 3.0.1 prior-art path (status JSON feed only).
|
||||
config_files: []
|
||||
|
||||
# PE section map from prior art (documentation + VA→file helper tests).
|
||||
pe_sections:
|
||||
- name: .text
|
||||
raw_offset: 0x400
|
||||
virtual_start: 0x140001000
|
||||
- name: .idata
|
||||
raw_offset: 0x01A5A200
|
||||
virtual_start: 0x141A5B000
|
||||
- name: .data
|
||||
raw_offset: 0x027F9000
|
||||
virtual_start: 0x1427FA000
|
||||
|
||||
# Logical string budgets (padded .idata slots). Stock bodies not required for plan.
|
||||
strings:
|
||||
status_json:
|
||||
stock: ""
|
||||
payload_budget_bytes: 0x3B5
|
||||
template: "{{.StatusJSONURL}}"
|
||||
fsd_jwt:
|
||||
stock: ""
|
||||
payload_budget_bytes: 0x3B4
|
||||
template: "{{.JWTURL}}"
|
||||
|
||||
us_free_slots: []
|
||||
|
||||
mutations:
|
||||
# --- Relocated string slots (padded) ---
|
||||
- id: write_status_json
|
||||
kind: padded_string
|
||||
description: Write openfsd status.json URL into .idata free slot (UTF-8)
|
||||
file_offset: 0x1AAE7AE
|
||||
available_bytes: 0x3B5
|
||||
encoding: utf8
|
||||
endpoint_key: status_json
|
||||
|
||||
- id: write_fsd_jwt
|
||||
kind: padded_string
|
||||
description: Write openfsd fsd-jwt URL into .idata free slot (UTF-16LE)
|
||||
file_offset: 0x1A9582C
|
||||
available_bytes: 0x3B4
|
||||
encoding: utf16le
|
||||
endpoint_key: fsd_jwt
|
||||
|
||||
# --- LEA RIP fixups (content-independent; point at relocated slots) ---
|
||||
- id: patch_status_lea
|
||||
kind: raw_overwrite
|
||||
description: Overwrite status.json LEA RIP displacement
|
||||
file_offset: 0x2810E
|
||||
new_bytes: [0x9C, 0x68, 0xA8, 0x01]
|
||||
|
||||
- id: patch_fsd_jwt_lea_1
|
||||
kind: raw_overwrite
|
||||
description: Overwrite fsd-jwt LEA RIP (site 1)
|
||||
file_offset: 0x34FFE
|
||||
new_bytes: [0x2A, 0x0A, 0xA6, 0x01]
|
||||
|
||||
- id: patch_fsd_jwt_lea_2
|
||||
kind: raw_overwrite
|
||||
description: Overwrite fsd-jwt LEA RIP (site 2)
|
||||
file_offset: 0x6B208
|
||||
new_bytes: [0x20, 0xA8, 0xA2, 0x01]
|
||||
|
||||
# --- Dynamic single-byte length immediates (Apply fills from endpoint URL len) ---
|
||||
- id: patch_status_len
|
||||
kind: raw_overwrite
|
||||
description: status.json URL length immediate (1 byte = rune/byte count)
|
||||
file_offset: 0x28107
|
||||
length_of: status_json
|
||||
|
||||
- id: patch_fsd_jwt_len_1
|
||||
kind: raw_overwrite
|
||||
description: fsd-jwt URL length immediate site 1
|
||||
file_offset: 0x3500A
|
||||
length_of: fsd_jwt
|
||||
|
||||
- id: patch_fsd_jwt_len_2
|
||||
kind: raw_overwrite
|
||||
description: fsd-jwt URL length immediate site 2
|
||||
file_offset: 0x6B214
|
||||
length_of: fsd_jwt
|
||||
|
||||
# --- Neutralise stock FSD auto host ---
|
||||
- id: break_fsd_vatsim_net
|
||||
kind: raw_overwrite
|
||||
description: Overwrite fsd.vatsim.net residual with "foo"
|
||||
file_offset: 0x1CBE440
|
||||
new_bytes: [0x66, 0x6F, 0x6F]
|
||||
|
||||
launch: {}
|
||||
22
third_party/client-profiles/xpilot-3.0.1.yaml
vendored
Normal file
22
third_party/client-profiles/xpilot-3.0.1.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# openfsd client profile — metadata only. Do NOT ship xPilot binaries.
|
||||
# Prior art: openfsd-client-patch-utility example_patchfiles/xpilot/xpilot-3.0.1.yaml
|
||||
# Legal: user must install xPilot themselves; openfsd never redistributes it.
|
||||
|
||||
client_id: xpilot
|
||||
display_name: xPilot
|
||||
vendor: Justin Shannon / xPilot project
|
||||
license_note: Proprietary; not redistributable by openfsd
|
||||
profile_version: "1"
|
||||
supported_client_version: "3.0.1"
|
||||
download_page: https://github.com/xpilot-project/xpilot/releases
|
||||
primary_binary:
|
||||
relative_path: xPilot.exe
|
||||
default_install_globs:
|
||||
windows:
|
||||
- "%ProgramFiles%\\xPilot\\xPilot.exe"
|
||||
- "%ProgramFiles(x86)%\\xPilot\\xPilot.exe"
|
||||
sha1: 1ae61e1d4a624751124a49cd992c90f948c31d37
|
||||
pe:
|
||||
clr: false
|
||||
architecture: x86_64
|
||||
prior_art_profile: xpilot-3.0.1 # openfsd-client-patch-utility
|
||||
Reference in New Issue
Block a user