| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/rootstate" |
| 11 | ) |
| 12 | |
| 13 | func TestPlan14OperatorUnitMigration(t *testing.T) { |
| 14 | for _, attack := range []string{"none", "no consent", "changed bytes", "replacement inode", "replacement invocation", "lost intent", "lost stop", "lost replace", "lost completion"} { |
| 15 | t.Run(attack, func(t *testing.T) { |
| 16 | b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "") |
| 17 | old := []byte("[Service]\nExecStart=/opt/carlos/live/shop.example\n") |
| 18 | shape.Program = strings.Repeat("a", 64) |
| 19 | shape.PredecessorDigest = claimUnitDigest(old) |
| 20 | if attack == "no consent" { |
| 21 | shape.PredecessorDigest = "" |
| 22 | } |
| 23 | b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil } |
| 24 | binding, err := b.establish(t.Context(), in.Host) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | files := &plan14UnitFiles{body: old, id: plan14UnitIdentity(2)} |
| 29 | state := &plan14UnitBindingFault{Store: &rollbackMemory{files: map[string][]byte{}}} |
| 30 | stopped, replaced := false, false |
| 31 | kernel := claimUnitPredecessor{Unit: shape.Unit, Invocation: "old", Group: "system.slice/console.service", GroupIdentity: rootstate.FileIdentity{Device: 3, Inode: 4}} |
| 32 | m := claimUnitMaterializer{state: state, read: files.read, create: files.create, replace: files.replace, idle: func(string) error { |
| 33 | if !stopped { |
| 34 | return os.ErrPermission |
| 35 | } |
| 36 | return nil |
| 37 | }} |
| 38 | m.predecessor = func(context.Context, claimUnitBinding) (claimUnitPredecessor, error) { return kernel, nil } |
| 39 | m.stopPredecessor = func(_ context.Context, got claimUnitPredecessor) error { |
| 40 | r, e := m.load(in.Host) |
| 41 | if e != nil || r.Migration == nil || r.Migration.Predecessor != got { |
| 42 | t.Fatal("stop without durable exact intention", e) |
| 43 | } |
| 44 | if got != kernel { |
| 45 | return os.ErrPermission |
| 46 | } |
| 47 | stopped = true |
| 48 | if attack == "lost stop" { |
| 49 | return os.ErrInvalid |
| 50 | } |
| 51 | return nil |
| 52 | } |
| 53 | m.replace = func(unit string, id rootstate.FileIdentity, body []byte) error { |
| 54 | if !stopped { |
| 55 | t.Fatal("replace before predecessor stop") |
| 56 | } |
| 57 | if err := files.replace(unit, id, body); err != nil { |
| 58 | return err |
| 59 | } |
| 60 | replaced = true |
| 61 | if attack == "lost completion" { |
| 62 | state.fail = true |
| 63 | } |
| 64 | if attack == "lost replace" { |
| 65 | return os.ErrInvalid |
| 66 | } |
| 67 | return nil |
| 68 | } |
| 69 | if attack == "changed bytes" { |
| 70 | files.body = append(files.body, 'x') |
| 71 | } |
| 72 | if attack == "lost intent" { |
| 73 | state.fail = true |
| 74 | } |
| 75 | if attack == "replacement inode" || attack == "replacement invocation" { |
| 76 | // Persist the initial intention, then lose the reply before any effect. |
| 77 | state.fail = true |
| 78 | if m.install(t.Context(), binding) == nil { |
| 79 | t.Fatal("lost intention accepted") |
| 80 | } |
| 81 | state.fail = false |
| 82 | if attack == "replacement inode" { |
| 83 | files.id.Inode++ |
| 84 | } else { |
| 85 | kernel.Invocation = "new" |
| 86 | } |
| 87 | } |
| 88 | err = m.install(t.Context(), binding) |
| 89 | if attack == "none" { |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | } else if err == nil { |
| 94 | t.Fatal("attack accepted") |
| 95 | } |
| 96 | if attack == "no consent" || attack == "changed bytes" || attack == "replacement inode" || attack == "replacement invocation" || attack == "lost intent" { |
| 97 | if stopped || replaced { |
| 98 | t.Fatal("unowned predecessor changed") |
| 99 | } |
| 100 | return |
| 101 | } |
| 102 | state.fail = false |
| 103 | m.stopPredecessor = func(_ context.Context, got claimUnitPredecessor) error { |
| 104 | if got != kernel { |
| 105 | return os.ErrPermission |
| 106 | } |
| 107 | stopped = true |
| 108 | return nil |
| 109 | } |
| 110 | m.replace = files.replace |
| 111 | if err = m.install(t.Context(), binding); err != nil { |
| 112 | t.Fatal("resume", err) |
| 113 | } |
| 114 | if files.retires != 1 { |
| 115 | t.Fatal("repeated replacement", files.retires) |
| 116 | } |
| 117 | if err = m.verify(binding); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | files.id.Inode++ |
| 121 | if err = m.install(t.Context(), binding); !errors.Is(err, os.ErrPermission) { |
| 122 | t.Fatal("rebound completed identity", err) |
| 123 | } |
| 124 | }) |
| 125 | } |
| 126 | } |
| 127 | |