mirror of
https://github.com/renorris/openfsd
synced 2026-08-12 12:25:40 +08:00
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.
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
63
internal/clientinject/launch.go
Normal file
63
internal/clientinject/launch.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
435
internal/clientinject/launch_shadow.go
Normal file
435
internal/clientinject/launch_shadow.go
Normal file
@@ -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)
|
||||
}
|
||||
452
internal/clientinject/launch_shadow_test.go
Normal file
452
internal/clientinject/launch_shadow_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user