From 26b9e299f5e6ecfd6726d6ebad4467e01b8698f7 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Tue, 28 Jul 2026 23:15:26 -0400 Subject: [PATCH] openfsd-client: hybrid shadow PE launch Phase 1 ephemeral launch: patch a temp PE copy while cwd remains the install root so DLLs resolve; durable config stays default. --- cmd/openfsd-client/run.go | 44 +- cmd/openfsd-client/run_test.go | 20 + internal/clientinject/doc.go | 8 +- internal/clientinject/engine_test.go | 6 +- internal/clientinject/launch.go | 63 +++ internal/clientinject/launch_shadow.go | 435 +++++++++++++++++++ internal/clientinject/launch_shadow_test.go | 452 ++++++++++++++++++++ 7 files changed, 1022 insertions(+), 6 deletions(-) create mode 100644 internal/clientinject/launch.go create mode 100644 internal/clientinject/launch_shadow.go create mode 100644 internal/clientinject/launch_shadow_test.go diff --git a/cmd/openfsd-client/run.go b/cmd/openfsd-client/run.go index 3f04133..a9c53f4 100644 --- a/cmd/openfsd-client/run.go +++ b/cmd/openfsd-client/run.go @@ -45,6 +45,11 @@ Shared flags for plan|apply|health|launch: --client ID Client adapter id (default vpilot) --profiles-dir DIR Optional override profile directory (YAML) +Launch-only flags: + --ephemeral Phase 1 hybrid shadow PE: patch a temp PE copy, cwd=install + root (DLLs resolve from install); durable config rewrite + remains default. Install PE stays stock. + 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 @@ -160,6 +165,7 @@ type endpointFlags struct { install string client string profilesDir string + ephemeral bool // launch --ephemeral (hybrid shadow PE) } func parseEndpointFlags(name string, args []string, stderr io.Writer) (*endpointFlags, []string, int) { @@ -176,6 +182,8 @@ func parseEndpointFlags(name string, args []string, stderr io.Writer) (*endpoint fs.StringVar(&ef.install, "install", "", "client install directory") fs.StringVar(&ef.client, "client", "vpilot", "client id") fs.StringVar(&ef.profilesDir, "profiles-dir", "", "optional profiles directory") + // Launch-only; harmless no-op if passed to other subcommands. + fs.BoolVar(&ef.ephemeral, "ephemeral", false, "hybrid shadow PE launch (temp PE, cwd=install, durable config)") if err := fs.Parse(args); err != nil { return nil, nil, ExitUsage } @@ -299,10 +307,40 @@ func cmdLaunch(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "no adapter for %q\n", install.ClientID) return ExitUsage } + if !ef.ephemeral { + launchArgs := a.LaunchArgs(install, ep) + pe := clientinject.AbsPrimaryPE(install) + fmt.Fprintf(stdout, "exec: %s %s\n", pe, strings.Join(launchArgs, " ")) + fmt.Fprintf(stdout, "(launch is dry-print only without --ephemeral; use --ephemeral for Phase 1 hybrid shadow PE)\n") + return ExitOK + } + + // Phase 1 hybrid shadow PE launch (Appendix B). + plan, err := eng.Plan(context.Background(), install, ep) + if err != nil { + return mapPlanErr(err, stderr) + } + printPlan(stdout, plan) + if len(plan.Blockers) > 0 { + fmt.Fprintf(stderr, "plan has blockers; refuse ephemeral launch\n") + return ExitPlanBlockers + } launchArgs := a.LaunchArgs(install, ep) - pe := clientinject.AbsPrimaryPE(install) - fmt.Fprintf(stdout, "exec: %s %s\n", pe, strings.Join(launchArgs, " ")) - fmt.Fprintf(stdout, "(launch is dry-print only in headless CLI; start the client with these args from the install directory)\n") + // Prefer plan launch_flag args when present. + if fromPlan := clientinject.LaunchArgsFromPlan(plan); len(fromPlan) > 0 { + launchArgs = fromPlan + } + fmt.Fprintf(stdout, "ephemeral shadow launch: cwd=%s pe=temp-copy durable_config=true\n", install.RootDir) + fmt.Fprintf(stdout, "launch args: %s\n", strings.Join(launchArgs, " ")) + session, err := eng.LaunchShadow(context.Background(), plan, launchArgs, clientinject.ShadowLaunchConfig{}) + if err != nil { + fmt.Fprintf(stderr, "ephemeral launch: %v\n", err) + return ExitApplyFailed + } + if session != nil { + fmt.Fprintf(stdout, "ephemeral launch finished (temp_kept=%v install_pe_stock_fp=%s)\n", + session.KeptTemp(), session.InstallPEFingerprint) + } return ExitOK }) } diff --git a/cmd/openfsd-client/run_test.go b/cmd/openfsd-client/run_test.go index cd0dcc2..d20f23f 100644 --- a/cmd/openfsd-client/run_test.go +++ b/cmd/openfsd-client/run_test.go @@ -63,3 +63,23 @@ func TestRun_Detect(t *testing.T) { } // May find zero installs on this machine; just ensure no crash. } + +func TestRun_HelpMentionsEphemeral(t *testing.T) { + var out, errBuf bytes.Buffer + code := Run([]string{"help"}, &out, &errBuf) + if code != ExitOK { + t.Fatalf("code=%d err=%s", code, errBuf.String()) + } + if !strings.Contains(out.String(), "--ephemeral") { + t.Fatalf("help missing --ephemeral: %s", out.String()) + } +} + +func TestRun_LaunchDryPrintWithoutEphemeral(t *testing.T) { + // Missing install → usage; ensures flag parsing accepts launch without ephemeral. + var out, errBuf bytes.Buffer + code := Run([]string{"launch", "--web-base", "https://x.test", "--fsd-host", "x.test"}, &out, &errBuf) + if code != ExitUsage { + t.Fatalf("code=%d err=%s", code, errBuf.String()) + } +} diff --git a/internal/clientinject/doc.go b/internal/clientinject/doc.go index 4ebdfcc..0ab40ff 100644 --- a/internal/clientinject/doc.go +++ b/internal/clientinject/doc.go @@ -1,12 +1,16 @@ // Package clientinject provides the openfsd Client Setup engine: version-pinned -// profiles, adapter plan/apply/revert, transactional .openfsd-bak backups, and -// running-process preflight for on-disk client reconfiguration. +// profiles, adapter plan/apply/revert, transactional .openfsd-bak backups, +// running-process preflight, and Phase 1 hybrid shadow PE launch. // // Pure subpackages (stdlib only): // - pepatch — PE/binary overwrite helpers // - cilus — CLR #US heap encode/decode // - vpilotconfig — vPilot 3DES config crypto + XML field rewrite // +// Hybrid shadow launch (LaunchShadow / --ephemeral): patch a temp copy of the +// PE while cwd remains the install root so DLLs resolve; durable config rewrite +// is the default; install PE stays stock. +// // This package must not import server, web, afv, db, postoffice, session, // cluster, sweatbox, metar, auth, or serviceapi. package clientinject diff --git a/internal/clientinject/engine_test.go b/internal/clientinject/engine_test.go index 0167a62..bb60584 100644 --- a/internal/clientinject/engine_test.go +++ b/internal/clientinject/engine_test.go @@ -69,8 +69,12 @@ func (f *FakeAdapter) Apply(ctx context.Context, plan *Plan, w FileWriter) error return errors.New("fake apply boom") } for _, m := range plan.Mutations { + // Launch flags are plan-only (no disk write), same as production adapters. + if m.Kind == MutLaunchFlag { + continue + } path := m.TargetRel - if !filepath.IsAbs(path) { + if path != "" && !filepath.IsAbs(path) { path = filepath.Join(plan.Install.RootDir, path) } switch d := m.Detail.(type) { diff --git a/internal/clientinject/launch.go b/internal/clientinject/launch.go new file mode 100644 index 0000000..6f4298f --- /dev/null +++ b/internal/clientinject/launch.go @@ -0,0 +1,63 @@ +package clientinject + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +// ProcessRunner executes a client binary. Injectable for tests so shadow +// launch can be exercised without a real PE on the host. +type ProcessRunner interface { + // Run starts name with args and working directory dir, waiting until exit. + Run(ctx context.Context, name string, args []string, dir string) error +} + +// DefaultProcessRunner runs the process via os/exec with stdio inherited. +type DefaultProcessRunner struct{} + +// Run implements ProcessRunner. +func (DefaultProcessRunner) Run(ctx context.Context, name string, args []string, dir string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("clientinject: run %s: %w", name, err) + } + return nil +} + +// LaunchArgsFromPlan extracts launch CLI args from MutLaunchFlag mutations. +// Returns nil when the plan has no launch_flag mutation. +func LaunchArgsFromPlan(plan *Plan) []string { + if plan == nil { + return nil + } + for _, m := range plan.Mutations { + if m.Kind != MutLaunchFlag { + continue + } + switch d := m.Detail.(type) { + case LaunchFlagDetail: + return append([]string(nil), d.Args...) + case *LaunchFlagDetail: + if d != nil { + return append([]string(nil), d.Args...) + } + } + } + return nil +} + +// IsPEMutationKind reports whether kind mutates a PE/DLL binary (not config/launch). +func IsPEMutationKind(k MutationKind) bool { + switch k { + case MutUSHeapString, MutLdstrRemap, MutRawOverwrite, MutPaddedString, MutAFVDisablePE: + return true + default: + return false + } +} diff --git a/internal/clientinject/launch_shadow.go b/internal/clientinject/launch_shadow.go new file mode 100644 index 0000000..1cdd327 --- /dev/null +++ b/internal/clientinject/launch_shadow.go @@ -0,0 +1,435 @@ +package clientinject + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// ShadowLaunchConfig controls hybrid Phase 1 shadow PE launch (Appendix B). +// +// Default hybrid algorithm: +// 1. Install root stays intact (DLLs/plugins stay put). +// 2. Temp dir via os.MkdirTemp("", "openfsd-client-shadow-*"). +// 3. Copy only files that will be PE-mutated (typically vPilot.exe). +// 4. Apply PE mutations to temp copies. +// 5. Config: durable install rewrite by default (not temp). +// 6. Launch: exec temp PE with Dir = installRoot. +// 7. On exit: best-effort delete temp copies. +type ShadowLaunchConfig struct { + // DurableConfig applies config rewrites to the install root (default true). + // When false, config mutation targets are also copied into the temp tree + // (research escape hatch; not the Phase 1 default). + DurableConfig *bool + + // KeepTemp skips temp-dir deletion after the process exits (debug/tests). + KeepTemp bool + + // TempDir, when non-empty, is used instead of MkdirTemp (tests). + TempDir string + + // Runner defaults to DefaultProcessRunner. + Runner ProcessRunner + + // MkdirTemp overrides os.MkdirTemp (tests). + MkdirTemp func(dir, pattern string) (string, error) + + // RemoveAll overrides os.RemoveAll (tests). + RemoveAll func(path string) error +} + +func (c ShadowLaunchConfig) durableConfig() bool { + if c.DurableConfig == nil { + return true + } + return *c.DurableConfig +} + +func (c ShadowLaunchConfig) runner() ProcessRunner { + if c.Runner != nil { + return c.Runner + } + return DefaultProcessRunner{} +} + +func (c ShadowLaunchConfig) mkdirTemp(dir, pattern string) (string, error) { + if c.MkdirTemp != nil { + return c.MkdirTemp(dir, pattern) + } + return os.MkdirTemp(dir, pattern) +} + +func (c ShadowLaunchConfig) removeAll(path string) error { + if c.RemoveAll != nil { + return c.RemoveAll(path) + } + return os.RemoveAll(path) +} + +// ShadowSession describes a prepared hybrid shadow environment. +type ShadowSession struct { + // InstallRoot is the user install tree (CWD for the launched PE). + InstallRoot string + // TempDir holds shadowed PE copies (and optional non-durable config). + TempDir string + // TempPE is the absolute path of the shadowed primary PE. + TempPE string + // LaunchArgs are CLI flags for the client process. + LaunchArgs []string + // ShadowedFiles maps install absolute path → temp absolute path. + ShadowedFiles map[string]string + // StockPEChecksum is a cheap fingerprint of install PE bytes before launch + // (len + first 32 bytes hex) so tests can assert the install PE was left stock. + // Empty when no primary PE was shadowed. + InstallPEFingerprint string + + keepTemp bool + removeAll func(path string) error + cleaned bool +} + +// KeptTemp reports whether Cleanup intentionally left the temp directory +// (ShadowLaunchConfig.KeepTemp). +func (s *ShadowSession) KeptTemp() bool { + return s != nil && s.keepTemp +} + +// Cleanup best-effort removes the temp shadow directory. +// Safe to call multiple times. No-op when KeepTemp was set. +func (s *ShadowSession) Cleanup() error { + if s == nil || s.cleaned || s.keepTemp || s.TempDir == "" { + return nil + } + s.cleaned = true + rm := s.removeAll + if rm == nil { + rm = os.RemoveAll + } + if err := rm(s.TempDir); err != nil { + return fmt.Errorf("clientinject: shadow cleanup %s: %w", s.TempDir, err) + } + return nil +} + +// CollectShadowTargets returns unique absolute install paths that must be +// copied into the shadow temp dir (PE mutation targets only when durableConfig). +// Config paths are excluded when durableConfig is true. +func CollectShadowTargets(plan *Plan, durableConfig bool) []string { + if plan == nil { + return nil + } + seen := make(map[string]struct{}) + var out []string + add := func(p string) { + if p == "" { + return + } + if !filepath.IsAbs(p) { + p = filepath.Join(plan.Install.RootDir, p) + } + p = filepath.Clean(p) + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + + hasPEMut := false + for _, m := range plan.Mutations { + if !IsPEMutationKind(m.Kind) { + continue + } + hasPEMut = true + if m.TargetRel != "" { + add(m.TargetRel) + } + } + if hasPEMut { + add(AbsPrimaryPE(plan.Install)) + } + + if !durableConfig { + // Escape hatch: also shadow config rewrite targets. + for _, m := range plan.Mutations { + if m.Kind != MutConfigRewrite { + continue + } + if m.TargetRel != "" { + add(m.TargetRel) + } + switch d := m.Detail.(type) { + case ConfigRewriteDetail: + for _, p := range d.Paths { + add(p) + } + case *ConfigRewriteDetail: + if d != nil { + for _, p := range d.Paths { + add(p) + } + } + } + } + } + return out +} + +// PrepareShadowLaunch creates the temp dir, copies shadow targets, and returns +// a session plus a plan whose PE mutations target the temp copies. +// Mutations are not applied yet. +// +// When durableConfig is true (default), config mutations still point at the +// install tree; only PE/DLL mutation targets are redirected. +func PrepareShadowLaunch(plan *Plan, w FileWriter, cfg ShadowLaunchConfig) (*ShadowSession, *Plan, error) { + if plan == nil { + return nil, nil, fmt.Errorf("clientinject: nil plan") + } + if len(plan.Blockers) > 0 { + return nil, nil, fmt.Errorf("clientinject: plan has blockers: %s", strings.Join(plan.Blockers, "; ")) + } + if w == nil { + w = OSFileWriter{} + } + install := normalizeInstall(plan.Install) + if install.RootDir == "" { + return nil, nil, fmt.Errorf("clientinject: empty install root") + } + + durable := cfg.durableConfig() + targets := CollectShadowTargets(plan, durable) + + tempDir := cfg.TempDir + if tempDir == "" { + d, err := cfg.mkdirTemp("", "openfsd-client-shadow-*") + if err != nil { + return nil, nil, fmt.Errorf("clientinject: mkdir temp shadow: %w", err) + } + tempDir = d + } else { + if err := w.MkdirAll(tempDir, 0o700); err != nil { + return nil, nil, fmt.Errorf("clientinject: mkdir shadow dir %s: %w", tempDir, err) + } + } + + session := &ShadowSession{ + InstallRoot: install.RootDir, + TempDir: tempDir, + LaunchArgs: LaunchArgsFromPlan(plan), + ShadowedFiles: make(map[string]string), + keepTemp: cfg.KeepTemp, + removeAll: cfg.removeAll, + } + + // Fingerprint install primary PE before any copy (stock proof). + pe := AbsPrimaryPE(install) + if pe != "" { + if data, err := w.ReadFile(pe); err == nil { + session.InstallPEFingerprint = peFingerprint(data) + } + } + + // Copy each target into temp by basename (flat; no full-tree copy — KD-21). + // Collision on basename is refused. + baseSeen := make(map[string]string) // basename → install path + for _, src := range targets { + if _, err := w.Stat(src); err != nil { + // Missing targets skipped (Apply may create config when non-durable). + continue + } + base := filepath.Base(src) + if prev, ok := baseSeen[base]; ok && prev != src { + _ = session.Cleanup() + return nil, nil, fmt.Errorf("clientinject: shadow basename collision %q (%s vs %s)", base, prev, src) + } + baseSeen[base] = src + dst := filepath.Join(tempDir, base) + if err := w.CopyFile(src, dst); err != nil { + _ = session.Cleanup() + return nil, nil, fmt.Errorf("clientinject: shadow copy %s: %w", src, err) + } + session.ShadowedFiles[src] = dst + if pe != "" && filepath.Clean(src) == filepath.Clean(pe) { + session.TempPE = dst + } + } + + // If no PE mutation targets were copied but we have a primary PE, still + // shadow the PE when the plan intends to launch a stock binary from temp + // (launch_flag-only plans). Prefer launching install PE with Dir=root in + // that case — TempPE stays empty and LaunchShadow uses install PE. + // (Design: copy only files that will be mutated.) + + shadowPlan := clonePlanForShadow(plan, install, session, durable) + return session, shadowPlan, nil +} + +// clonePlanForShadow redirects PE mutation targets to temp copies. +func clonePlanForShadow(plan *Plan, install Install, session *ShadowSession, durableConfig bool) *Plan { + out := &Plan{ + Install: install, + Endpoints: plan.Endpoints, + Mutations: make([]Mutation, 0, len(plan.Mutations)), + Constraints: append([]Constraint(nil), plan.Constraints...), + Warnings: append([]string(nil), plan.Warnings...), + Blockers: append([]string(nil), plan.Blockers...), + } + if session.TempPE != "" { + out.Install.PrimaryPE = session.TempPE + } + + for _, m := range plan.Mutations { + nm := m + switch { + case IsPEMutationKind(m.Kind): + // Redirect TargetRel to the temp absolute path when shadowed. + src := m.TargetRel + if src == "" { + src = AbsPrimaryPE(install) + } else if !filepath.IsAbs(src) { + src = filepath.Join(install.RootDir, src) + } + src = filepath.Clean(src) + if dst, ok := session.ShadowedFiles[src]; ok { + nm.TargetRel = dst + } + case m.Kind == MutConfigRewrite && !durableConfig: + // Rewrite config paths into the temp tree. + switch d := m.Detail.(type) { + case ConfigRewriteDetail: + nd := d + nd.Paths = remapPaths(d.Paths, session.ShadowedFiles, install.RootDir) + nm.Detail = nd + if m.TargetRel != "" { + nm.TargetRel = remapOne(m.TargetRel, session.ShadowedFiles, install.RootDir) + } + case *ConfigRewriteDetail: + if d != nil { + nd := *d + nd.Paths = remapPaths(d.Paths, session.ShadowedFiles, install.RootDir) + nm.Detail = &nd + } + if m.TargetRel != "" { + nm.TargetRel = remapOne(m.TargetRel, session.ShadowedFiles, install.RootDir) + } + } + } + out.Mutations = append(out.Mutations, nm) + } + return out +} + +func remapPaths(paths []string, shadowed map[string]string, root string) []string { + if len(paths) == 0 { + return nil + } + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = remapOne(p, shadowed, root) + } + return out +} + +func remapOne(p string, shadowed map[string]string, root string) string { + if p == "" { + return p + } + abs := p + if !filepath.IsAbs(abs) { + abs = filepath.Join(root, abs) + } + abs = filepath.Clean(abs) + if dst, ok := shadowed[abs]; ok { + return dst + } + // Not copied (missing source): place under temp by basename when possible. + // Callers that need creation should have copied or accept install path. + return p +} + +func peFingerprint(data []byte) string { + n := len(data) + const head = 32 + h := data + if len(h) > head { + h = h[:head] + } + return fmt.Sprintf("%d:%x", n, h) +} + +// LaunchShadow prepares a hybrid shadow PE, applies mutations (PE→temp, +// config→install when durable), runs the process with Dir=installRoot, then +// best-effort cleans the temp dir. +// +// The install primary PE is left stock when only PE mutations were shadowed. +// Config is rewritten durably by default so CachedServers/status persist. +// +// launchArgs overrides plan launch_flag args when non-nil (empty slice clears). +func (e *Engine) LaunchShadow(ctx context.Context, plan *Plan, launchArgs []string, cfg ShadowLaunchConfig) (*ShadowSession, error) { + if plan == nil { + return nil, fmt.Errorf("clientinject: nil plan") + } + if len(plan.Blockers) > 0 { + return nil, fmt.Errorf("clientinject: plan has blockers: %s", strings.Join(plan.Blockers, "; ")) + } + a, ok := e.Adapters[plan.Install.ClientID] + if !ok { + return nil, fmt.Errorf("clientinject: no adapter for client %q", plan.Install.ClientID) + } + w := e.writer() + + session, shadowPlan, err := PrepareShadowLaunch(plan, w, cfg) + if err != nil { + return nil, err + } + + // Apply mutations: PE to temp (via redirected PrimaryPE), config durable. + if err := a.Apply(ctx, shadowPlan, w); err != nil { + _ = session.Cleanup() + return nil, fmt.Errorf("clientinject: shadow apply: %w", err) + } + + args := session.LaunchArgs + if launchArgs != nil { + args = append([]string(nil), launchArgs...) + session.LaunchArgs = args + } + + exe := session.TempPE + if exe == "" { + // No PE mutations — launch stock install PE from install root. + exe = AbsPrimaryPE(plan.Install) + } + if exe == "" { + _ = session.Cleanup() + return nil, fmt.Errorf("clientinject: no PE to launch") + } + session.TempPE = exe // may be install PE when nothing was shadowed + + runner := cfg.runner() + runErr := runner.Run(ctx, exe, args, session.InstallRoot) + + // Always try cleanup after process exit (or run failure). + if cerr := session.Cleanup(); cerr != nil { + slog.Warn("clientinject shadow temp cleanup failed", "temp", session.TempDir, "err", cerr) + if runErr == nil { + runErr = cerr + } + } + if runErr != nil { + return session, runErr + } + return session, nil +} + +// ShadowLaunch is a package-level convenience using the engine's writer/adapters. +// See Engine.LaunchShadow. +func ShadowLaunch(ctx context.Context, e *Engine, plan *Plan, launchArgs []string, cfg ShadowLaunchConfig) (*ShadowSession, error) { + if e == nil { + return nil, fmt.Errorf("clientinject: nil engine") + } + return e.LaunchShadow(ctx, plan, launchArgs, cfg) +} diff --git a/internal/clientinject/launch_shadow_test.go b/internal/clientinject/launch_shadow_test.go new file mode 100644 index 0000000..f983425 --- /dev/null +++ b/internal/clientinject/launch_shadow_test.go @@ -0,0 +1,452 @@ +package clientinject + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// fakeRunner records the last process invocation without executing a PE. +type fakeRunner struct { + mu sync.Mutex + calls int + name string + args []string + dir string + runErr error + onRun func(name string, args []string, dir string) +} + +func (f *fakeRunner) Run(_ context.Context, name string, args []string, dir string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + f.name = name + f.args = append([]string(nil), args...) + f.dir = dir + if f.onRun != nil { + f.onRun(name, args, dir) + } + return f.runErr +} + +func boolPtr(v bool) *bool { return &v } + +func TestIsPEMutationKind(t *testing.T) { + if !IsPEMutationKind(MutUSHeapString) || !IsPEMutationKind(MutRawOverwrite) { + t.Fatal("expected PE kinds") + } + if IsPEMutationKind(MutConfigRewrite) || IsPEMutationKind(MutLaunchFlag) { + t.Fatal("config/launch must not be PE kinds") + } +} + +func TestLaunchArgsFromPlan(t *testing.T) { + if LaunchArgsFromPlan(nil) != nil { + t.Fatal("nil plan") + } + plan := &Plan{Mutations: []Mutation{ + {Kind: MutConfigRewrite}, + {Kind: MutLaunchFlag, Detail: LaunchFlagDetail{Args: []string{"-novoice", "-x"}}}, + }} + got := LaunchArgsFromPlan(plan) + if len(got) != 2 || got[0] != "-novoice" || got[1] != "-x" { + t.Fatalf("got %v", got) + } +} + +func TestCollectShadowTargets_PEOnlyWhenDurable(t *testing.T) { + root := t.TempDir() + pe := filepath.Join(root, "app.exe") + cfg := filepath.Join(root, "config.xml") + plan := &Plan{ + Install: Install{RootDir: root, PrimaryPE: pe, ConfigPaths: []string{cfg}}, + Mutations: []Mutation{ + {Kind: MutUSHeapString, TargetRel: "app.exe"}, + {Kind: MutConfigRewrite, TargetRel: "config.xml", Detail: ConfigRewriteDetail{Paths: []string{cfg}}}, + {Kind: MutLaunchFlag, Detail: LaunchFlagDetail{Args: []string{"-novoice"}}}, + }, + } + got := CollectShadowTargets(plan, true) + if len(got) != 1 || got[0] != pe { + t.Fatalf("durable shadow targets=%v want only PE", got) + } + gotAll := CollectShadowTargets(plan, false) + if len(gotAll) != 2 { + t.Fatalf("non-durable want PE+config, got %v", gotAll) + } +} + +func TestLaunchShadow_PatchesTempLeavesInstallStock_CleansTemp(t *testing.T) { + root := t.TempDir() + pePath := filepath.Join(root, "app.exe") + payloadPath := filepath.Join(root, "payload.bin") + stockPE := []byte("MZ-STOCK-PE-BYTES-AAAAAAAA") + stockPayload := []byte("STOCK-PAYLOAD-CONTENT-XXXX") + if err := os.WriteFile(pePath, stockPE, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(payloadPath, stockPayload, 0o644); err != nil { + t.Fatal(err) + } + + // Side file that must never be copied (DLL / plugin stand-in). + dllPath := filepath.Join(root, "plugin.dll") + if err := os.WriteFile(dllPath, []byte("DLL-STOCK"), 0o644); err != nil { + t.Fatal(err) + } + + patched := []byte("OPENFSD-PATCHED-PAYLOAD") + fa := &FakeAdapter{ + ID: "fake", + Name: "Fake", + Profiles: []string{"fake-1"}, + TargetRel: "payload.bin", + NewData: patched, + } + // Profile store not required for LaunchShadow (no Plan/Resolve). + eng := NewEngine(nil, fa) + + plan := &Plan{ + Install: Install{ + ClientID: "fake", + RootDir: root, + PrimaryPE: pePath, + }, + Mutations: []Mutation{ + { + ID: "write_payload", + Kind: MutRawOverwrite, + TargetRel: "payload.bin", + Detail: WriteFileDetail{Contents: patched}, + }, + { + ID: "launch_flags", + Kind: MutLaunchFlag, + Detail: LaunchFlagDetail{Args: []string{"-serveraddressoverride", "h:6809", "-novoice"}}, + }, + }, + } + + var sawTempPayload []byte + runner := &fakeRunner{ + onRun: func(name string, args []string, dir string) { + if dir != root { + t.Errorf("Dir=%q want install root %q", dir, root) + } + // name should be shadowed PE under temp, not install PE. + if name == pePath { + t.Errorf("launched install PE directly; want temp shadow") + } + if !strings.Contains(name, "openfsd-client-shadow-") && !strings.Contains(filepath.Dir(name), "shadow") { + // TempDir may be custom; at least must not be install root for PE. + if filepath.Dir(name) == root { + t.Errorf("temp PE still under install root: %s", name) + } + } + // Patched payload should live next to temp PE. + tempPayload := filepath.Join(filepath.Dir(name), "payload.bin") + b, err := os.ReadFile(tempPayload) + if err != nil { + t.Errorf("read temp payload: %v", err) + return + } + sawTempPayload = append([]byte(nil), b...) + // Install tree must still be stock while process "runs". + livePE, _ := os.ReadFile(pePath) + if !bytes.Equal(livePE, stockPE) { + t.Errorf("install PE mutated during launch") + } + livePayload, _ := os.ReadFile(payloadPath) + if !bytes.Equal(livePayload, stockPayload) { + t.Errorf("install payload mutated during launch: %q", livePayload) + } + // DLL must never have been copied into temp. + if _, err := os.Stat(filepath.Join(filepath.Dir(name), "plugin.dll")); err == nil { + t.Errorf("plugin.dll was shadow-copied; hybrid must not full-tree copy") + } + if len(args) < 2 || args[0] != "-serveraddressoverride" { + t.Errorf("args=%v", args) + } + }, + } + + // Track temp dir creation for cleanup assertion. + var createdTemp string + cfg := ShadowLaunchConfig{ + Runner: runner, + MkdirTemp: func(dir, pattern string) (string, error) { + d, err := os.MkdirTemp(dir, pattern) + createdTemp = d + return d, err + }, + } + + session, err := eng.LaunchShadow(context.Background(), plan, nil, cfg) + if err != nil { + t.Fatalf("LaunchShadow: %v", err) + } + if runner.calls != 1 { + t.Fatalf("runner calls=%d", runner.calls) + } + if session == nil { + t.Fatal("nil session") + } + if session.InstallPEFingerprint == "" { + t.Fatal("expected install PE fingerprint") + } + // Install PE still stock after exit. + livePE, err := os.ReadFile(pePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(livePE, stockPE) { + t.Fatalf("install PE not stock after shadow launch: %q", livePE) + } + livePayload, _ := os.ReadFile(payloadPath) + if !bytes.Equal(livePayload, stockPayload) { + t.Fatalf("install payload not stock: %q", livePayload) + } + if !bytes.Equal(sawTempPayload, patched) { + t.Fatalf("temp payload not patched: %q", sawTempPayload) + } + // Temp dir best-effort deleted. + if createdTemp != "" { + if _, err := os.Stat(createdTemp); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temp dir not cleaned: %s err=%v", createdTemp, err) + } + } + // DLL untouched. + dll, _ := os.ReadFile(dllPath) + if !bytes.Equal(dll, []byte("DLL-STOCK")) { + t.Fatal("dll mutated") + } +} + +func TestLaunchShadow_DurableConfigRewritesInstall(t *testing.T) { + root := t.TempDir() + pePath := filepath.Join(root, "app.exe") + cfgPath := filepath.Join(root, "app.config") + stockPE := []byte("MZ-STOCK") + if err := os.WriteFile(pePath, stockPE, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfgPath, []byte("OLD-CONFIG"), 0o644); err != nil { + t.Fatal(err) + } + + // Adapter that writes config via WriteFileDetail and PE-ish file via TargetRel. + fa := &configAndPEAdapter{peRel: "app.exe", cfgPath: cfgPath, pePatch: []byte("SHADOW-PE"), cfgPatch: []byte("NEW-CONFIG")} + eng := NewEngine(nil, fa) + + plan := &Plan{ + Install: Install{ClientID: "cfgpe", RootDir: root, PrimaryPE: pePath}, + Mutations: []Mutation{ + { + ID: "pe", + Kind: MutRawOverwrite, + TargetRel: "app.exe", + Detail: WriteFileDetail{Contents: []byte("SHADOW-PE")}, + }, + { + ID: "cfg", + Kind: MutConfigRewrite, + TargetRel: "app.config", + Detail: WriteFileDetail{ // Fake path: adapter below handles MutConfigRewrite + Contents: []byte("NEW-CONFIG"), + }, + }, + }, + } + + runner := &fakeRunner{} + session, err := eng.LaunchShadow(context.Background(), plan, []string{"-novoice"}, ShadowLaunchConfig{ + Runner: runner, + DurableConfig: boolPtr(true), + }) + if err != nil { + t.Fatalf("LaunchShadow: %v", err) + } + _ = session + + // Install PE stock. + gotPE, _ := os.ReadFile(pePath) + if !bytes.Equal(gotPE, stockPE) { + t.Fatalf("install PE mutated: %q", gotPE) + } + // Durable config applied to install. + gotCfg, _ := os.ReadFile(cfgPath) + if !bytes.Equal(gotCfg, []byte("NEW-CONFIG")) { + t.Fatalf("config not durably rewritten: %q", gotCfg) + } + if runner.dir != root { + t.Fatalf("cwd=%q", runner.dir) + } + if runner.name == pePath { + t.Fatal("should launch temp PE, not install PE") + } +} + +// configAndPEAdapter applies WriteFileDetail for PE kinds to TargetRel and for +// MutConfigRewrite to the absolute cfg path (durable). +type configAndPEAdapter struct { + peRel, cfgPath string + pePatch, cfgPatch []byte +} + +func (a *configAndPEAdapter) ClientID() string { return "cfgpe" } +func (a *configAndPEAdapter) DisplayName() string { return "cfgpe" } +func (a *configAndPEAdapter) SupportedProfiles() []string { return nil } +func (a *configAndPEAdapter) Discover(context.Context) ([]InstallCandidate, error) { + return nil, nil +} +func (a *configAndPEAdapter) Verify(Install, *Profile) error { return nil } +func (a *configAndPEAdapter) EndpointConstraints(*Profile) []Constraint { return nil } +func (a *configAndPEAdapter) Plan(Install, *Profile, Endpoints) (*Plan, error) { + return nil, errors.New("unused") +} +func (a *configAndPEAdapter) HealthCheck(Install, Endpoints) error { return nil } +func (a *configAndPEAdapter) LaunchArgs(Install, Endpoints) []string { + return []string{"-novoice"} +} + +func (a *configAndPEAdapter) Apply(_ context.Context, plan *Plan, w FileWriter) error { + for _, m := range plan.Mutations { + switch m.Kind { + case MutRawOverwrite, MutUSHeapString, MutPaddedString, MutAFVDisablePE, MutLdstrRemap: + path := m.TargetRel + if path == "" { + path = AbsPrimaryPE(plan.Install) + } else if !filepath.IsAbs(path) { + path = filepath.Join(plan.Install.RootDir, path) + } + d, ok := m.Detail.(WriteFileDetail) + if !ok { + if p, ok2 := m.Detail.(*WriteFileDetail); ok2 && p != nil { + d = *p + } else { + return errors.New("bad PE detail") + } + } + if err := w.WriteFile(path, d.Contents); err != nil { + return err + } + case MutConfigRewrite: + // Always write durable path a.cfgPath (simulates absolute config paths). + d, ok := m.Detail.(WriteFileDetail) + if !ok { + if p, ok2 := m.Detail.(*WriteFileDetail); ok2 && p != nil { + d = *p + } else { + d = WriteFileDetail{Contents: a.cfgPatch} + } + } + if err := w.WriteFile(a.cfgPath, d.Contents); err != nil { + return err + } + case MutLaunchFlag: + continue + default: + return errors.New("unsupported") + } + } + return nil +} + +func TestPrepareShadowLaunch_Blockers(t *testing.T) { + _, _, err := PrepareShadowLaunch(&Plan{Blockers: []string{"x"}}, OSFileWriter{}, ShadowLaunchConfig{}) + if err == nil { + t.Fatal("expected blockers error") + } +} + +func TestLaunchShadow_CleanupOnApplyFailure(t *testing.T) { + root := t.TempDir() + pePath := filepath.Join(root, "app.exe") + if err := os.WriteFile(pePath, []byte("MZ"), 0o644); err != nil { + t.Fatal(err) + } + fa := &FakeAdapter{ID: "fake", Name: "F", FailApply: true, TargetRel: "payload.bin"} + // Need payload for shadow copy? Fake apply fails before write — still prepare copies PE. + if err := os.WriteFile(filepath.Join(root, "payload.bin"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + eng := NewEngine(nil, fa) + plan := &Plan{ + Install: Install{ClientID: "fake", RootDir: root, PrimaryPE: pePath}, + Mutations: []Mutation{{ + ID: "w", Kind: MutRawOverwrite, TargetRel: "payload.bin", + Detail: WriteFileDetail{Contents: []byte("y")}, + }}, + } + var created string + _, err := eng.LaunchShadow(context.Background(), plan, nil, ShadowLaunchConfig{ + Runner: &fakeRunner{}, + MkdirTemp: func(dir, pattern string) (string, error) { + d, err := os.MkdirTemp(dir, pattern) + created = d + return d, err + }, + }) + if err == nil { + t.Fatal("expected apply failure") + } + if created != "" { + if _, st := os.Stat(created); !errors.Is(st, os.ErrNotExist) { + t.Fatalf("temp not cleaned after apply fail: %v", st) + } + } +} + +func TestLaunchShadow_KeepTemp(t *testing.T) { + root := t.TempDir() + pePath := filepath.Join(root, "app.exe") + payload := filepath.Join(root, "payload.bin") + _ = os.WriteFile(pePath, []byte("MZ"), 0o644) + _ = os.WriteFile(payload, []byte("stock"), 0o644) + fa := &FakeAdapter{ID: "fake", Name: "F", TargetRel: "payload.bin", NewData: []byte("new")} + eng := NewEngine(nil, fa) + plan := &Plan{ + Install: Install{ClientID: "fake", RootDir: root, PrimaryPE: pePath}, + Mutations: []Mutation{{ + ID: "w", Kind: MutRawOverwrite, TargetRel: "payload.bin", + Detail: WriteFileDetail{Contents: []byte("new")}, + }}, + } + var created string + session, err := eng.LaunchShadow(context.Background(), plan, nil, ShadowLaunchConfig{ + KeepTemp: true, + Runner: &fakeRunner{}, + MkdirTemp: func(dir, pattern string) (string, error) { + d, err := os.MkdirTemp(dir, pattern) + created = d + return d, err + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(created); err != nil { + t.Fatalf("KeepTemp should leave dir: %v", err) + } + // Manual cleanup for test hygiene. + if err := session.Cleanup(); err != nil { + // Cleanup is no-op when keepTemp — remove manually. + _ = os.RemoveAll(created) + } else { + // keepTemp means Cleanup is no-op + _ = os.RemoveAll(created) + } +} + +func TestShadowLaunch_NilEngine(t *testing.T) { + _, err := ShadowLaunch(context.Background(), nil, &Plan{}, nil, ShadowLaunchConfig{}) + if err == nil { + t.Fatal("expected error") + } +}