//go:build linux package instanceexec import ( "encoding/json" "errors" "os" "path/filepath" "testing" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/provision" ) func writeLaunch(t *testing.T, dir string, role instancectl.Role, l instancectl.Launch) { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } b, err := json.Marshal(l) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, instancectl.LaunchFileName(role)), b, 0o600); err != nil { t.Fatal(err) } } type captured struct { argv0 string argv []string env []string calls int } func (c *captured) exec(argv0 string, argv, env []string) error { c.calls++ c.argv0, c.argv, c.env = argv0, argv, env return nil } func TestExecLaunchExecsExactlyWhatTheAgentWrote(t *testing.T) { data := t.TempDir() iid := "bill.example.com" dir := filepath.Join(data, "instances", iid) writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{ Schema: 1, Host: "bill.example.com", Argv: []string{"/opt/carlos/live/bill.example.com", "--socket", "/run/carlos/instances/bill.example.com/app.sock", "--db", "/data/instances/bill.example.com/bill.example.com.db"}, Env: []string{"PATH=/usr/bin", "CARLOS_ADMIN_TOKEN=abc"}, }) var c captured if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err != nil { t.Fatalf("execLaunch: %v", err) } if c.calls != 1 || c.argv0 != "/opt/carlos/live/bill.example.com" || len(c.argv) != 5 || c.argv[2] != "/run/carlos/instances/bill.example.com/app.sock" { t.Errorf("exec = %+v", c) } if len(c.env) != 2 || c.env[1] != "CARLOS_ADMIN_TOKEN=abc" { t.Errorf("env = %v", c.env) } } func TestExecLaunchRefusesAnArgv0OutsideTheLiveDir(t *testing.T) { data := t.TempDir() iid := "h.example.com" dir := filepath.Join(data, "instances", iid) for _, argv0 := range []string{"/bin/sh", "/opt/carlos/live/../carlos", "/opt/carlos/live/other.example.com", "opt/carlos/live/h.example.com", ""} { writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: []string{argv0}, Env: nil}) var c captured err := execLaunch(data, instancectl.RoleRun, iid, c.exec) if err == nil || c.calls != 0 { t.Errorf("argv0 %q: err = %v, calls = %d; want a refusal and no exec", argv0, err, c.calls) } } } func TestExecLaunchRefusesUnknownSchemaAndBadEnv(t *testing.T) { data := t.TempDir() iid := "h.example.com" dir := filepath.Join(data, "instances", iid) good := []string{"/opt/carlos/live/h.example.com"} writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 2, Host: "h.example.com", Argv: good}) var c captured if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { t.Errorf("schema 2: err = %v, calls = %d", err, c.calls) } writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: good, Env: []string{"NOEQUALS"}}) if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { t.Errorf("bad env: err = %v, calls = %d", err, c.calls) } writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: "not a host", Argv: []string{"/opt/carlos/live/not a host"}}) if err := execLaunch(data, instancectl.RoleRun, iid, c.exec); err == nil || c.calls != 0 { t.Errorf("bad host: err = %v, calls = %d", err, c.calls) } } func TestExecLaunchRefusesASymlinkedLaunchFile(t *testing.T) { data := t.TempDir() iid := "h.example.com" dir := filepath.Join(data, "instances", iid) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } other := filepath.Join(t.TempDir(), "launch.run.json") l := instancectl.Launch{Schema: 1, Host: "h.example.com", Argv: []string{"/opt/carlos/live/h.example.com"}} b, _ := json.Marshal(l) if err := os.WriteFile(other, b, 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(other, filepath.Join(dir, "launch.run.json")); err != nil { t.Fatal(err) } var c captured err := execLaunch(data, instancectl.RoleRun, iid, c.exec) if err == nil || c.calls != 0 || !errors.Is(err, errSymlinkOrEscape) { t.Errorf("symlinked launch file: err = %v, calls = %d", err, c.calls) } } func TestExecLaunchRefusesAHostThatIsNotThisInstance(t *testing.T) { // Case 1: launch file in instances/a.example.com/ with Host: b.example.com data := t.TempDir() iidA := "a.example.com" dirA := filepath.Join(data, "instances", iidA) writeLaunch(t, dirA, instancectl.RoleRun, instancectl.Launch{ Schema: 1, Host: "b.example.com", Argv: []string{"/opt/carlos/live/b.example.com"}, }) var c captured if err := execLaunch(data, instancectl.RoleRun, iidA, c.exec); err == nil || c.calls != 0 { t.Errorf("host mismatch (short): err = %v, calls = %d; want refusal", err, c.calls) } // Case 2: long hostname (60 bytes), directory name is provision.InstanceID(long), Host is long long := "a" + "b" + "c" + "d" + "e" + "f" + "g" + "h" + "i" + "j" + "k" + "l" + "m" + "n" + "o" + "p" + "q" + "r" + "s" + "t" + "u" + "v" + "w" + "x" + "y" + "z" + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "a" + "b" + "c" + "d" + "e" + "f" + "g" + "h" + "i" + "j" + "k" + "l" + "m" + "n" + "o" + "p" + "q" + "r" + "s" + "t" + "u" + "v" + "w" + "x" if len(long) != 60 { t.Fatalf("long host is %d bytes, want 60", len(long)) } iidLong := provision.InstanceID(long) dirLong := filepath.Join(data, "instances", iidLong) writeLaunch(t, dirLong, instancectl.RoleRun, instancectl.Launch{ Schema: 1, Host: long, Argv: []string{"/opt/carlos/live/" + long}, }) c2 := captured{} if err := execLaunch(data, instancectl.RoleRun, iidLong, c2.exec); err != nil { t.Errorf("host match (long): err = %v; want no error", err) } if c2.calls != 1 { t.Errorf("host match (long): calls = %d; want 1", c2.calls) } } func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { data := t.TempDir() host := "bill.example.com" dir := filepath.Join(data, "instances", host) // The legacy launch file is a decoy: a generation must never read it. writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "legacy"}}) raw, err := json.Marshal(instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "--socket", "/run/carlos/instances/" + host + "/g7/app.sock"}}) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, instancectl.GenLaunchFileName(7)), raw, 0o600); err != nil { t.Fatal(err) } var c captured if err := execLaunch(data, instancectl.RoleRun, instancectl.GenInstance(host, 7), c.exec); err != nil { t.Fatalf("execLaunch: %v", err) } if c.calls != 1 || len(c.argv) != 3 || c.argv[2] != "/run/carlos/instances/"+host+"/g7/app.sock" { t.Errorf("exec = %+v", c) } } // Each malformed instance string has an otherwise valid, host-matching // launch file planted wherever a lenient parser could look, so only the // parser can refuse it: the refusal must be launchTarget's own. func TestExecLaunchRefusesMalformedOrMisplacedGenerations(t *testing.T) { data := t.TempDir() host := "bill.example.com" dir := filepath.Join(data, "instances", host) plant := func(dir string, n uint64, l instancectl.Launch) { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } raw, err := json.Marshal(l) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, instancectl.GenLaunchFileName(n)), raw, 0o600); err != nil { t.Fatal(err) } } valid := instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host}} for _, n := range []uint64{0, 1, 7} { plant(dir, n, valid) } // Where a parser that let "", ".." or "a:b" through would look. plant(filepath.Join(data, "instances"), 7, valid) plant(filepath.Join(data, "x"), 1, valid) plant(filepath.Join(data, "instances", "a:b"), 1, valid) plant(filepath.Join(data, "instances", "a"), 1, valid) // The planted file is otherwise valid: the well-formed instance runs it. var ok captured if err := execLaunch(data, instancectl.RoleRun, host+":7", ok.exec); err != nil || ok.calls != 1 { t.Fatalf("the planted file was refused for a valid instance: %v, %d execs", err, ok.calls) } for _, tc := range []struct { role instancectl.Role inst string }{ {instancectl.RoleRun, host + ":0"}, {instancectl.RoleRun, host + ":07"}, {instancectl.RoleRun, ":7"}, {instancectl.RoleRun, host + ":"}, {instancectl.RoleRun, "../x:1"}, {instancectl.RoleRun, "a:b:1"}, {instancectl.RoleSidecar, host + ":7"}, {instancectl.RoleSeed, host + ":7"}, } { _, _, perr := launchTarget(tc.role, tc.inst) if perr == nil { t.Errorf("launchTarget(%s, %q) accepted it", tc.role, tc.inst) continue } var c captured err := execLaunch(data, tc.role, tc.inst, c.exec) if err == nil || c.calls != 0 || err.Error() != perr.Error() { t.Errorf("execLaunch(%s, %q) = %v after %d execs; want the parser's refusal %q and no exec", tc.role, tc.inst, err, c.calls, perr) } } // Misplaced: a well-formed generation whose file names another host. plant(dir, 9, instancectl.Launch{Schema: 1, Host: "other.example.com", Argv: []string{"/opt/carlos/live/other.example.com"}}) var c captured if err := execLaunch(data, instancectl.RoleRun, host+":9", c.exec); err == nil || c.calls != 0 { t.Errorf("a generation file naming another host: %v after %d execs; want a refusal", err, c.calls) } }