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:
Reese Norris
2026-07-28 23:33:46 -04:00
parent d44086b9a7
commit 694c3bb7ee
15 changed files with 1511 additions and 33 deletions

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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) {

View 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.

View File

@@ -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(),
}
}

View File

@@ -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))
}
}

View 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
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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,
})
}

View File

@@ -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"`

View File

@@ -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) {

View 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: {}

View 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