//go:build linux package host import ( "context" "net" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/rootstate" "golang.org/x/sys/unix" ) func TestPlan14FlatSocketMigration(t *testing.T) { for _, attack := range []string{"none", "replacement", "boot", "lost-rename", "archive-replacement", "erased"} { t.Run(attack, func(t *testing.T) { dir := t.TempDir() source, archive := filepath.Join(dir, "old.sock"), filepath.Join(dir, "retained.sock") listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) if err != nil { t.Fatal(err) } listener.SetUnlinkOnClose(false) defer listener.Close() id, err := captureClaimUnitSocket(source, "boot-one") if err != nil { t.Fatal(err) } listener.Close() boot := "boot-one" switch attack { case "erased": if err := os.Remove(source); err != nil { t.Fatal(err) } case "boot": boot = "boot-two" case "replacement": if err := os.Rename(source, source+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(source, []byte("replacement"), 0600); err != nil { t.Fatal(err) } case "lost-rename", "archive-replacement": if err := os.Rename(source, archive); err != nil { t.Fatal(err) } if attack == "archive-replacement" { if err := os.Rename(archive, archive+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(archive, []byte("replacement"), 0600); err != nil { t.Fatal(err) } } } renames := 0 err = parkClaimUnitSocketWith(source, archive, boot, id, func(sfd int, src string, dfd int, dst string, flags uint) error { renames++ return unix.Renameat2(sfd, src, dfd, dst, flags) }) if attack == "none" || attack == "lost-rename" || attack == "erased" { if err != nil { t.Fatal(err) } if err = parkClaimUnitSocket(source, archive, boot, id); err != nil { t.Fatal("replay", err) } if _, err := os.Lstat(source); !os.IsNotExist(err) { t.Fatal("old socket still occupies routing name", err) } } else { if err == nil { t.Fatal("unowned socket accepted") } if renames != 0 { t.Fatal("unowned socket renamed", renames) } } }) } } func TestPlan14OperatorSocketMigrationDurableRetry(t *testing.T) { for _, loss := range []string{"stop", "park", "replace"} { t.Run(loss, func(t *testing.T) { b, in, shape := plan14UnitBindingFixture(t, "woodstar.service", "", "/run/carlos/woodstar.sock") old := []byte("[Service]\nExecStart=/old/program\n") shape.Program, shape.PredecessorDigest = strings.Repeat("a", 64), claimUnitDigest(old) b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } binding, err := b.establish(t.Context(), in.Host) if err != nil { t.Fatal(err) } dir := t.TempDir() source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) if err != nil { t.Fatal(err) } ln.SetUnlinkOnClose(false) defer ln.Close() want, err := captureClaimUnitSocket(source, "boot") if err != nil { t.Fatal(err) } files := &plan14UnitFiles{body: old, id: plan14UnitIdentity(2)} state := &rollbackMemory{files: map[string][]byte{}} p := claimUnitPredecessor{Unit: shape.Unit, Invocation: "old", Group: "system.slice/woodstar.service", Boot: "boot", GroupIdentity: rootstate.FileIdentity{Device: 3, Inode: 4}} stopped, parked := false, false m := claimUnitMaterializer{state: state, read: files.read, create: files.create, idle: func(string) error { if !stopped { return os.ErrPermission } return nil }} m.predecessor = func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) { return p, nil } m.socketCapture = func(context.Context, claimUnitShape, claimUnitPredecessor) (claimUnitSocketIdentity, error) { return want, nil } m.stopPredecessor = func(context.Context, claimUnitPredecessor) error { r, err := m.load(in.Host) if err != nil || r.Migration == nil || r.Migration.Socket != want { t.Fatal("stop without durable socket identity", err) } stopped = true if loss == "stop" { return os.ErrClosed } return nil } m.socketPark = func(_ claimUnitBinding, id claimUnitSocketIdentity) error { if !stopped || id != want { t.Fatal("socket park before exact predecessor stop") } if err := parkClaimUnitSocket(source, archive, "boot", id); err != nil { return err } parked = true if loss == "park" { return os.ErrClosed } return nil } m.replace = func(unit string, id rootstate.FileIdentity, raw []byte) error { if !parked { t.Fatal("unit replaced before socket migration") } if err := files.replace(unit, id, raw); err != nil { return err } if loss == "replace" { return os.ErrClosed } return nil } if err := m.install(t.Context(), binding); err == nil { t.Fatal("lost reply accepted") } loss = "" if err := m.install(t.Context(), binding); err != nil { t.Fatal("durable retry", err) } if err := m.verify(binding); err != nil || files.retires != 1 { t.Fatal("migration did not finish exactly once", err, files.retires) } }) } } func TestPlan14FlatSocketMigrationRenameRace(t *testing.T) { for _, occupied := range []bool{false, true} { t.Run(map[bool]string{false: "restore", true: "occupied"}[occupied], func(t *testing.T) { dir := t.TempDir() source, archive := filepath.Join(dir, "source.sock"), filepath.Join(dir, "archive.sock") ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: source, Net: "unix"}) if err != nil { t.Fatal(err) } ln.SetUnlinkOnClose(false) defer ln.Close() id, err := captureClaimUnitSocket(source, "boot") if err != nil { t.Fatal(err) } err = parkClaimUnitSocketWith(source, archive, "boot", id, func(sfd int, src string, dfd int, dst string, flags uint) error { if err := os.Rename(source, source+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(source, []byte("raced"), 0600); err != nil { t.Fatal(err) } err := unix.Renameat2(sfd, src, dfd, dst, flags) if occupied && err == nil { err = os.WriteFile(source, []byte("newest"), 0600) } return err }) if err == nil { t.Fatal("raced replacement accepted") } path, want := source, "raced" if occupied { path, want = archive, "raced" got, e := os.ReadFile(source) if e != nil || string(got) != "newest" { t.Fatal("new source overwritten", e) } } got, err := os.ReadFile(path) if err != nil || string(got) != want { t.Fatal("raced file lost", string(got), err) } }) } }