| 1 | package hostfiles |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | const pfx = "instances/shop.test/abc/files/g1/" |
| 17 | |
| 18 | func open(t *testing.T, s release.Store) *Publisher { |
| 19 | t.Helper() |
| 20 | p, err := Open(context.Background(), s, pfx) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | return p |
| 25 | } |
| 26 | |
| 27 | func stage(t *testing.T, p *Publisher, path, body string) { |
| 28 | t.Helper() |
| 29 | if err := p.Stage(context.Background(), path, []byte(body), 0o600, false); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func commit(t *testing.T, p *Publisher) int64 { |
| 35 | t.Helper() |
| 36 | seq, err := p.Commit(context.Background()) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | return seq |
| 41 | } |
| 42 | |
| 43 | func read(t *testing.T, dir, rel string) string { |
| 44 | t.Helper() |
| 45 | b, err := os.ReadFile(filepath.Join(dir, rel)) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | return string(b) |
| 50 | } |
| 51 | |
| 52 | func TestPublishThenRestoreRoundTrips(t *testing.T) { |
| 53 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 54 | p := open(t, s) |
| 55 | stage(t, p, "instance.key", "k1") |
| 56 | stage(t, p, "uploads/a/b.txt", "hello") |
| 57 | if seq := commit(t, p); seq != 2 { |
| 58 | t.Fatalf("seq = %d, want 2", seq) |
| 59 | } |
| 60 | dir := t.TempDir() |
| 61 | m, st, err := Restore(ctx, s, pfx, dir, nil) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if m.Seq != 2 || st.Written != 2 { |
| 66 | t.Fatalf("manifest seq %d, written %d", m.Seq, st.Written) |
| 67 | } |
| 68 | if read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a/b.txt") != "hello" { |
| 69 | t.Fatal("content differs") |
| 70 | } |
| 71 | fi, _ := os.Stat(filepath.Join(dir, "instance.key")) |
| 72 | if fi.Mode().Perm() != 0o600 { |
| 73 | t.Fatalf("mode = %v, want 0600", fi.Mode().Perm()) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestALaterWriteWinsAndAnUnchangedOneTakesNoSequence(t *testing.T) { |
| 78 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 79 | p := open(t, s) |
| 80 | stage(t, p, "instance.key", "old") |
| 81 | commit(t, p) |
| 82 | stage(t, p, "instance.key", "new") |
| 83 | stage(t, p, "instance.key", "new") // unchanged: no new sequence number |
| 84 | if seq := commit(t, p); seq != 2 { |
| 85 | t.Fatalf("seq = %d, want 2", seq) |
| 86 | } |
| 87 | dir := t.TempDir() |
| 88 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | if got := read(t, dir, "instance.key"); got != "new" { |
| 92 | t.Fatalf("restored %q, want new", got) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // A1-4: a stalled upload of the OLD content that lands after the new |
| 97 | // content was published must not come back. |
| 98 | func TestALateUploadCannotResurrectOldContent(t *testing.T) { |
| 99 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 100 | p := open(t, s) |
| 101 | stage(t, p, "instance.key", "new") |
| 102 | commit(t, p) |
| 103 | // The late arrival: any object at all under objects/, with a higher number. |
| 104 | if _, err := s.Put(ctx, pfx+"objects/0000000000000099-deadbeef", []byte("old"), ""); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | dir := t.TempDir() |
| 108 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if got := read(t, dir, "instance.key"); got != "new" { |
| 112 | t.Fatalf("restored %q, want new", got) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestADeleteIsATombstoneAndRestoreRemovesTheFile(t *testing.T) { |
| 117 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 118 | p := open(t, s) |
| 119 | stage(t, p, "a.txt", "a") |
| 120 | stage(t, p, "b.txt", "b") |
| 121 | commit(t, p) |
| 122 | dir := t.TempDir() |
| 123 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if err := p.StageDelete("a.txt"); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | commit(t, p) |
| 130 | _, st, err := Restore(ctx, s, pfx, dir, nil) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | if _, err := os.Stat(filepath.Join(dir, "a.txt")); !os.IsNotExist(err) { |
| 135 | t.Fatalf("a.txt still there: %v", err) |
| 136 | } |
| 137 | if st.Removed != 1 || read(t, dir, "b.txt") != "b" { |
| 138 | t.Fatalf("removed %d", st.Removed) |
| 139 | } |
| 140 | if got := p.Paths(); len(got) != 1 || got[0] != "b.txt" { |
| 141 | t.Fatalf("Paths = %v", got) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestASecondWriterIsSupersededNotMerged(t *testing.T) { |
| 146 | s := release.NewDirStore(t.TempDir()) |
| 147 | a, b := open(t, s), open(t, s) |
| 148 | stage(t, a, "x", "from a") |
| 149 | commit(t, a) |
| 150 | stage(t, b, "x", "from b") |
| 151 | if _, err := b.Commit(context.Background()); !errors.Is(err, ErrSuperseded) { |
| 152 | t.Fatalf("err = %v, want ErrSuperseded", err) |
| 153 | } |
| 154 | dir := t.TempDir() |
| 155 | if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | if got := read(t, dir, "x"); got != "from a" { |
| 159 | t.Fatalf("restored %q, want from a", got) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestAReopenedPublisherContinuesTheSequence(t *testing.T) { |
| 164 | s := release.NewDirStore(t.TempDir()) |
| 165 | p := open(t, s) |
| 166 | stage(t, p, "x", "1") |
| 167 | commit(t, p) |
| 168 | q := open(t, s) |
| 169 | stage(t, q, "y", "2") |
| 170 | if seq := commit(t, q); seq != 2 { |
| 171 | t.Fatalf("seq = %d, want 2", seq) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // Review Focus 1. |
| 176 | func TestCleanPathRefusesEscapes(t *testing.T) { |
| 177 | for _, bad := range []string{"", "/etc/passwd", "../x", "a/../../x", "a/../b", "./a", "a//b", "a/", ".", "..", "a\x00b"} { |
| 178 | if _, err := CleanPath(bad); err == nil { |
| 179 | t.Errorf("CleanPath(%q) accepted", bad) |
| 180 | } |
| 181 | } |
| 182 | for _, ok := range []string{"a", "a/b.txt", ".hidden", "a b/c", `back\slash`} { |
| 183 | if got, err := CleanPath(ok); err != nil || got != ok { |
| 184 | t.Errorf("CleanPath(%q) = %q, %v", ok, got, err) |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | func TestStageAndRestoreRefuseAnEscapingPath(t *testing.T) { |
| 190 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 191 | p := open(t, s) |
| 192 | if err := p.Stage(ctx, "../evil", []byte("x"), 0o600, false); err == nil { |
| 193 | t.Fatal("Stage accepted ../evil") |
| 194 | } |
| 195 | // A manifest written by something else with a hostile path. |
| 196 | body := `{"v":2,"seq":1,"entries":{"../evil":{"object":"objects/0000000000000001-aa","sha256":"aa","seq":1}},"platform":{}}` |
| 197 | hostile := "instances/h/i/files/g/" |
| 198 | if _, err := s.Put(ctx, ManifestKey(hostile), []byte(body), ""); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | parent := t.TempDir() |
| 202 | dir := filepath.Join(parent, "files") |
| 203 | if err := os.Mkdir(dir, 0o755); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | if _, _, err := Restore(ctx, s, hostile, dir, nil); err == nil { |
| 207 | t.Fatal("Restore accepted a manifest with ../evil") |
| 208 | } |
| 209 | if _, err := os.Stat(filepath.Join(parent, "evil")); !os.IsNotExist(err) { |
| 210 | t.Fatal("../evil was written") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestRestoreRefusesContentThatDoesNotMatchItsHash(t *testing.T) { |
| 215 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 216 | p := open(t, s) |
| 217 | stage(t, p, "x", "good") |
| 218 | commit(t, p) |
| 219 | m, _, err := Load(ctx, s, pfx) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | key := m.Entries["x"].Object |
| 224 | _, etag, _ := s.Get(ctx, key) |
| 225 | if _, err := s.Put(ctx, key, []byte("tampered"), etag); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if _, _, err := Restore(ctx, s, pfx, t.TempDir(), nil); err == nil || !strings.Contains(err.Error(), "sha256") { |
| 229 | t.Fatalf("err = %v, want a sha256 mismatch", err) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestAnAbsentManifestRestoresNothing(t *testing.T) { |
| 234 | m, st, err := Restore(context.Background(), release.NewDirStore(t.TempDir()), pfx, t.TempDir(), nil) |
| 235 | if err != nil || m.Seq != 0 || st.Written != 0 { |
| 236 | t.Fatalf("m=%+v st=%+v err=%v", m, st, err) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestPlatformItemsAreNotWrittenIntoTheFilesDirectory(t *testing.T) { |
| 241 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 242 | p := open(t, s) |
| 243 | if err := p.StagePlatform(ctx, "wake.json", []byte(`{"v":1}`)); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | commit(t, p) |
| 247 | dir := t.TempDir() |
| 248 | m, _, err := Restore(ctx, s, pfx, dir, nil) |
| 249 | if err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | if entries, _ := os.ReadDir(dir); len(entries) != 0 { |
| 253 | t.Fatalf("files directory has %d entries, want 0", len(entries)) |
| 254 | } |
| 255 | got, err := ReadPlatform(ctx, s, pfx, m, "wake.json", nil) |
| 256 | if err != nil || string(got) != `{"v":1}` { |
| 257 | t.Fatalf("ReadPlatform = %q, %v", got, err) |
| 258 | } |
| 259 | if _, err := ReadPlatform(ctx, s, pfx, m, "absent", nil); !errors.Is(err, os.ErrNotExist) { |
| 260 | t.Fatalf("absent item: err = %v, want os.ErrNotExist", err) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Review Focus 3: a skipped file stays in the manifest, visibly. |
| 265 | func TestASkippedFileIsRecordedAndReportedOnRestore(t *testing.T) { |
| 266 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 267 | p := open(t, s) |
| 268 | if err := p.StageSkipped("huge.bin", "too-large"); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | commit(t, p) |
| 272 | _, st, err := Restore(ctx, s, pfx, t.TempDir(), nil) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | if len(st.Skipped) != 1 || st.Skipped[0] != "huge.bin" { |
| 277 | t.Fatalf("Skipped = %v", st.Skipped) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // A destination that already holds a symlink pointing elsewhere must not |
| 282 | // let a write or a delete reach through it. |
| 283 | func TestRestoreNeverWritesOrDeletesThroughASymlink(t *testing.T) { |
| 284 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 285 | p := open(t, s) |
| 286 | stage(t, p, "sub/x", "ours") |
| 287 | stage(t, p, "gone/y", "temp") |
| 288 | commit(t, p) |
| 289 | if err := p.StageDelete("gone/y"); err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | commit(t, p) |
| 293 | |
| 294 | outside, dir := t.TempDir(), t.TempDir() |
| 295 | if err := os.WriteFile(filepath.Join(outside, "y"), []byte("not ours"), 0o600); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | for _, link := range []string{"sub", "gone"} { |
| 299 | if err := os.Symlink(outside, filepath.Join(dir, link)); err != nil { |
| 300 | t.Fatal(err) |
| 301 | } |
| 302 | } |
| 303 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if _, err := os.Stat(filepath.Join(outside, "x")); !os.IsNotExist(err) { |
| 307 | t.Fatal("a file was written through the symlink") |
| 308 | } |
| 309 | if got := read(t, outside, "y"); got != "not ours" { |
| 310 | t.Fatalf("a file outside was changed to %q", got) |
| 311 | } |
| 312 | if fi, err := os.Lstat(filepath.Join(dir, "sub")); err != nil || !fi.IsDir() { |
| 313 | t.Fatalf("sub is not a real directory: %v %v", fi, err) |
| 314 | } |
| 315 | if got := read(t, dir, "sub/x"); got != "ours" { |
| 316 | t.Fatalf("sub/x = %q", got) |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) { |
| 321 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 322 | p := open(t, s) |
| 323 | stage(t, p, "a/b", "child") |
| 324 | commit(t, p) |
| 325 | prior := t.TempDir() // a copy restored while "a" was a directory |
| 326 | if _, _, err := Restore(ctx, s, pfx, prior, nil); err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | |
| 330 | // "a" becomes a file. |
| 331 | if err := p.StageDelete("a/b"); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | stage(t, p, "a", "now a file") |
| 335 | commit(t, p) |
| 336 | for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} { |
| 337 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 338 | t.Fatalf("%s: %v", name, err) |
| 339 | } |
| 340 | if got := read(t, dir, "a"); got != "now a file" { |
| 341 | t.Fatalf("%s: a = %q", name, got) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | // And back: "a" becomes a directory again. |
| 346 | if err := p.StageDelete("a"); err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | stage(t, p, "a/c", "child again") |
| 350 | commit(t, p) |
| 351 | for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} { |
| 352 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 353 | t.Fatalf("%s: %v", name, err) |
| 354 | } |
| 355 | if got := read(t, dir, "a/c"); got != "child again" { |
| 356 | t.Fatalf("%s: a/c = %q", name, got) |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | func TestRestoreIntoAMissingDirectoryIsAnError(t *testing.T) { |
| 362 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 363 | p := open(t, s) |
| 364 | stage(t, p, "x", "x") |
| 365 | commit(t, p) |
| 366 | if _, _, err := Restore(ctx, s, pfx, filepath.Join(t.TempDir(), "absent"), nil); err == nil { |
| 367 | t.Fatal("restored into a directory that does not exist") |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // Plan round 2, finding 3: a restore that returns nil has made its names |
| 372 | // durable, and one that could not must say so. |
| 373 | func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) { |
| 374 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 375 | p := open(t, s) |
| 376 | stage(t, p, "top.txt", "t") |
| 377 | stage(t, p, "a/b/deep.txt", "d") |
| 378 | stage(t, p, "gone/old.txt", "o") |
| 379 | commit(t, p) |
| 380 | dir := t.TempDir() |
| 381 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 382 | t.Fatal(err) |
| 383 | } |
| 384 | if err := p.StageDelete("gone/old.txt"); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | commit(t, p) |
| 388 | |
| 389 | var synced []string |
| 390 | orig := syncDir |
| 391 | t.Cleanup(func() { syncDir = orig }) |
| 392 | syncDir = func(root *os.Root, rel string) error { |
| 393 | // Everything must already be in place when the first sync runs. |
| 394 | if len(synced) == 0 { |
| 395 | if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) { |
| 396 | t.Error("a directory was synced before the tombstone was applied") |
| 397 | } |
| 398 | } |
| 399 | synced = append(synced, rel) |
| 400 | return orig(root, rel) |
| 401 | } |
| 402 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 403 | t.Fatal(err) |
| 404 | } |
| 405 | want := []string{"a/b", "a", "gone", "."} |
| 406 | if !reflect.DeepEqual(synced, want) { |
| 407 | t.Fatalf("synced %v, want %v (deepest first, the root last)", synced, want) |
| 408 | } |
| 409 | |
| 410 | syncDir = func(*os.Root, string) error { return errors.New("disk says no") } |
| 411 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil || !strings.Contains(err.Error(), "disk says no") { |
| 412 | t.Fatalf("err = %v; a failed directory sync must fail the restore", err) |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | // Plan round 3: a restore that failed at its directory sync has already |
| 417 | // removed the file. The retry finds nothing to remove, and must still |
| 418 | // make that removal durable before it reports success. |
| 419 | func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) { |
| 420 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 421 | p := open(t, s) |
| 422 | stage(t, p, "gone/old.txt", "o") |
| 423 | commit(t, p) |
| 424 | dir := t.TempDir() |
| 425 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | if err := p.StageDelete("gone/old.txt"); err != nil { |
| 429 | t.Fatal(err) |
| 430 | } |
| 431 | commit(t, p) |
| 432 | |
| 433 | orig := syncDir |
| 434 | t.Cleanup(func() { syncDir = orig }) |
| 435 | syncDir = func(*os.Root, string) error { return errors.New("disk says no") } |
| 436 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil { |
| 437 | t.Fatal("the first attempt must fail") |
| 438 | } |
| 439 | if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) { |
| 440 | t.Fatal("the first attempt should have removed the file before failing") |
| 441 | } |
| 442 | var synced []string |
| 443 | syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) } |
| 444 | _, st, err := Restore(ctx, s, pfx, dir, nil) |
| 445 | if err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if st.Removed != 0 { |
| 449 | t.Fatalf("Removed = %d; the retry had nothing left to remove", st.Removed) |
| 450 | } |
| 451 | if want := []string{"gone", "."}; !reflect.DeepEqual(synced, want) { |
| 452 | t.Fatalf("the retry synced %v, want %v", synced, want) |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | // A deeper directory replaced by a file: the directories that vanish |
| 457 | // under the new file are not synced (they do not exist), and the restore |
| 458 | // still succeeds and syncs what survives. |
| 459 | func TestRestoreHandlesADeepDirectoryBecomingAFile(t *testing.T) { |
| 460 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 461 | p := open(t, s) |
| 462 | stage(t, p, "a/b/c", "leaf") |
| 463 | commit(t, p) |
| 464 | dir := t.TempDir() |
| 465 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 466 | t.Fatal(err) |
| 467 | } |
| 468 | if err := p.StageDelete("a/b/c"); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | stage(t, p, "a", "now a file") |
| 472 | commit(t, p) |
| 473 | |
| 474 | var synced []string |
| 475 | orig := syncDir |
| 476 | t.Cleanup(func() { syncDir = orig }) |
| 477 | syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) } |
| 478 | for name, d := range map[string]string{"a previously restored destination": dir, "an empty one": t.TempDir()} { |
| 479 | synced = nil |
| 480 | if _, _, err := Restore(ctx, s, pfx, d, nil); err != nil { |
| 481 | t.Fatalf("%s: %v", name, err) |
| 482 | } |
| 483 | if got := read(t, d, "a"); got != "now a file" { |
| 484 | t.Fatalf("%s: a = %q", name, got) |
| 485 | } |
| 486 | if want := []string{"."}; !reflect.DeepEqual(synced, want) { |
| 487 | t.Fatalf("%s: synced %v, want %v: a/b and a are gone, the root records it", name, synced, want) |
| 488 | } |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | // A store whose manifest Put is applied and then reports a lost response, once. |
| 493 | type lostResponseStore struct { |
| 494 | release.Store |
| 495 | armed bool |
| 496 | getFail bool // the next Get of the manifest fails once |
| 497 | putFail bool // the next Put of the manifest fails once, unapplied |
| 498 | } |
| 499 | |
| 500 | func (l *lostResponseStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 501 | if l.getFail && strings.HasSuffix(key, "manifest.json") { |
| 502 | l.getFail = false |
| 503 | return nil, "", errors.New("read timed out") |
| 504 | } |
| 505 | return l.Store.Get(ctx, key) |
| 506 | } |
| 507 | |
| 508 | func (l *lostResponseStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| 509 | if l.putFail && strings.HasSuffix(key, "manifest.json") { |
| 510 | l.putFail = false |
| 511 | return "", errors.New("connection refused") |
| 512 | } |
| 513 | got, err := l.Store.Put(ctx, key, body, etag) |
| 514 | if err == nil && l.armed && strings.HasSuffix(key, "manifest.json") { |
| 515 | l.armed = false |
| 516 | return "", errors.New("connection reset") |
| 517 | } |
| 518 | return got, err |
| 519 | } |
| 520 | |
| 521 | func TestALostCommitResponseIsNotASupersede(t *testing.T) { |
| 522 | ctx := context.Background() |
| 523 | s := &lostResponseStore{Store: release.NewDirStore(t.TempDir())} |
| 524 | p := open(t, s) |
| 525 | stage(t, p, "x", "1") |
| 526 | s.armed = true |
| 527 | if _, err := p.Commit(ctx); err == nil || errors.Is(err, ErrSuperseded) { |
| 528 | t.Fatalf("first commit err = %v, want the transport error", err) |
| 529 | } |
| 530 | seq, err := p.Commit(ctx) |
| 531 | if err != nil || seq != 1 { |
| 532 | t.Fatalf("second commit = %d, %v", seq, err) |
| 533 | } |
| 534 | stage(t, p, "y", "2") |
| 535 | if seq := commit(t, p); seq != 2 { |
| 536 | t.Fatalf("seq = %d, want 2", seq) |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | func TestRestoreMasksTheModeToPermissionBits(t *testing.T) { |
| 541 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 542 | p := open(t, s) |
| 543 | stage(t, p, "x", "x") |
| 544 | commit(t, p) |
| 545 | m, etag, _, err := load(ctx, s, pfx) |
| 546 | if err != nil { |
| 547 | t.Fatal(err) |
| 548 | } |
| 549 | e := m.Entries["x"] |
| 550 | e.Mode = 0o4755 |
| 551 | m.Entries["x"] = e |
| 552 | body, _ := json.Marshal(m) |
| 553 | if _, err := s.Put(ctx, ManifestKey(pfx), body, etag); err != nil { |
| 554 | t.Fatal(err) |
| 555 | } |
| 556 | dir := t.TempDir() |
| 557 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil { |
| 558 | t.Fatal(err) |
| 559 | } |
| 560 | fi, err := os.Stat(filepath.Join(dir, "x")) |
| 561 | if err != nil { |
| 562 | t.Fatal(err) |
| 563 | } |
| 564 | if fi.Mode() != 0o755 { |
| 565 | t.Fatalf("mode = %v, want -rwxr-xr-x with no special bits", fi.Mode()) |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | func TestOverlappingLiveEntriesKeepTheNewerOne(t *testing.T) { |
| 570 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 571 | p := open(t, s) |
| 572 | stage(t, p, "a/b", "child") |
| 573 | stage(t, p, "a", "file") // newer than the child |
| 574 | commit(t, p) |
| 575 | dir := t.TempDir() |
| 576 | _, st, err := Restore(ctx, s, pfx, dir, nil) |
| 577 | if err != nil { |
| 578 | t.Fatal(err) |
| 579 | } |
| 580 | if got := read(t, dir, "a"); got != "file" || !reflect.DeepEqual(st.Superseded, []string{"a/b"}) || st.Written != 1 { |
| 581 | t.Fatalf("a = %q, superseded %v, written %d", got, st.Superseded, st.Written) |
| 582 | } |
| 583 | |
| 584 | q := open(t, release.NewDirStore(t.TempDir())) |
| 585 | stage(t, q, "a", "file") |
| 586 | stage(t, q, "a/b", "child") // newer than the file |
| 587 | commit(t, q) |
| 588 | dir2 := t.TempDir() |
| 589 | _, st, err = Restore(ctx, q.s, pfx, dir2, nil) |
| 590 | if err != nil { |
| 591 | t.Fatal(err) |
| 592 | } |
| 593 | if got := read(t, dir2, "a/b"); got != "child" || !reflect.DeepEqual(st.Superseded, []string{"a"}) || st.Written != 1 { |
| 594 | t.Fatalf("a/b = %q, superseded %v, written %d", got, st.Superseded, st.Written) |
| 595 | } |
| 596 | } |
| 597 | |
| 598 | func TestAFailedReReadAfterALostResponseIsNotASupersede(t *testing.T) { |
| 599 | ctx := context.Background() |
| 600 | s := &lostResponseStore{Store: release.NewDirStore(t.TempDir())} |
| 601 | p := open(t, s) |
| 602 | stage(t, p, "x", "1") |
| 603 | s.armed = true |
| 604 | if _, err := p.Commit(ctx); err == nil { |
| 605 | t.Fatal("first commit should fail") |
| 606 | } |
| 607 | s.getFail = true |
| 608 | if _, err := p.Commit(ctx); err == nil || errors.Is(err, ErrSuperseded) { |
| 609 | t.Fatalf("second commit err = %v, want the read error, not ErrSuperseded", err) |
| 610 | } |
| 611 | if seq, err := p.Commit(ctx); err != nil || seq != 1 { |
| 612 | t.Fatalf("third commit = %d, %v", seq, err) |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | func TestThreeDeepOverlapKeepsTheNewestEntry(t *testing.T) { |
| 617 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 618 | p := open(t, s) |
| 619 | stage(t, p, "a", "one") |
| 620 | stage(t, p, "a/b/c", "three") |
| 621 | stage(t, p, "a/b", "two") // the newest |
| 622 | commit(t, p) |
| 623 | dir := t.TempDir() |
| 624 | _, st, err := Restore(ctx, s, pfx, dir, nil) |
| 625 | if err != nil { |
| 626 | t.Fatal(err) |
| 627 | } |
| 628 | if got := read(t, dir, "a/b"); got != "two" || st.Written != 1 || !reflect.DeepEqual(st.Superseded, []string{"a", "a/b/c"}) { |
| 629 | t.Fatalf("a/b = %q, written %d, superseded %v", got, st.Written, st.Superseded) |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | // A Stage between a commit whose response was lost and its retry changes |
| 634 | // the body; the retry must still recognise the stored manifest as its own. |
| 635 | func TestALostCommitResponseFollowedByAStageIsNotASupersede(t *testing.T) { |
| 636 | ctx := context.Background() |
| 637 | s := &lostResponseStore{Store: release.NewDirStore(t.TempDir())} |
| 638 | p := open(t, s) |
| 639 | stage(t, p, "x", "1") |
| 640 | s.armed = true |
| 641 | if _, err := p.Commit(ctx); err == nil || errors.Is(err, ErrSuperseded) { |
| 642 | t.Fatalf("first commit err = %v, want the transport error", err) |
| 643 | } |
| 644 | stage(t, p, "y", "2") |
| 645 | seq, err := p.Commit(ctx) |
| 646 | if err != nil || seq != 2 { |
| 647 | t.Fatalf("second commit = %d, %v; want 2, nil", seq, err) |
| 648 | } |
| 649 | m, _, err := Load(ctx, s, pfx) |
| 650 | if err != nil { |
| 651 | t.Fatal(err) |
| 652 | } |
| 653 | if m.Seq != 2 || m.Entries["x"].Seq != 1 || m.Entries["y"].Seq != 2 { |
| 654 | t.Fatalf("stored manifest = %+v, want x@1 and y@2 at seq 2", m) |
| 655 | } |
| 656 | stage(t, p, "z", "3") |
| 657 | if seq := commit(t, p); seq != 3 { |
| 658 | t.Fatalf("third commit seq = %d, want 3", seq) |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | // Two failed attempts against the same stale etag: only one can have been |
| 663 | // applied, and whichever it was, the publisher recognises it. |
| 664 | func TestTwoFailedCommitsThenAStageRecognisesTheAppliedOne(t *testing.T) { |
| 665 | ctx := context.Background() |
| 666 | s := &lostResponseStore{Store: release.NewDirStore(t.TempDir())} |
| 667 | p := open(t, s) |
| 668 | stage(t, p, "x", "1") |
| 669 | s.armed = true |
| 670 | if _, err := p.Commit(ctx); err == nil { |
| 671 | t.Fatal("first commit should fail") |
| 672 | } |
| 673 | stage(t, p, "y", "2") |
| 674 | s.putFail = true // the second attempt never reaches the store |
| 675 | if _, err := p.Commit(ctx); err == nil || errors.Is(err, ErrSuperseded) { |
| 676 | t.Fatalf("second commit err = %v, want the transport error", err) |
| 677 | } |
| 678 | stage(t, p, "z", "3") |
| 679 | if seq, err := p.Commit(ctx); err != nil || seq != 3 { |
| 680 | t.Fatalf("third commit = %d, %v; want 3, nil", seq, err) |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | // putHostile publishes a good manifest with one file "a", lets bend alter |
| 685 | // the stored manifest, and returns a files directory that already holds a |
| 686 | // file Restore would remove or replace if it acted. |
| 687 | func putHostile(t *testing.T, bend func(m *Manifest)) (release.Store, string) { |
| 688 | t.Helper() |
| 689 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 690 | p := open(t, s) |
| 691 | stage(t, p, "a", "good") |
| 692 | stage(t, p, "gone", "soon") |
| 693 | commit(t, p) |
| 694 | if err := p.StageDelete("gone"); err != nil { |
| 695 | t.Fatal(err) |
| 696 | } |
| 697 | if err := p.StagePlatform(ctx, "item", []byte("plat")); err != nil { |
| 698 | t.Fatal(err) |
| 699 | } |
| 700 | commit(t, p) |
| 701 | m, etag, _, err := load(ctx, s, pfx) |
| 702 | if err != nil { |
| 703 | t.Fatal(err) |
| 704 | } |
| 705 | bend(&m) |
| 706 | body, _ := json.Marshal(m) |
| 707 | if _, err := s.Put(ctx, ManifestKey(pfx), body, etag); err != nil { |
| 708 | t.Fatal(err) |
| 709 | } |
| 710 | dir := t.TempDir() |
| 711 | if err := os.WriteFile(filepath.Join(dir, "gone"), []byte("still here"), 0o600); err != nil { |
| 712 | t.Fatal(err) |
| 713 | } |
| 714 | return s, dir |
| 715 | } |
| 716 | |
| 717 | func TestRestoreRefusesAnEntryWhoseObjectOrHashIsMalformedAndTouchesNothing(t *testing.T) { |
| 718 | cases := map[string]func(m *Manifest){ |
| 719 | "uppercase sha": func(m *Manifest) { |
| 720 | e := m.Entries["a"] |
| 721 | e.SHA256 = strings.ToUpper(e.SHA256) |
| 722 | e.Object = pfx + objectKey(e.Seq, e.SHA256) |
| 723 | m.Entries["a"] = e |
| 724 | }, |
| 725 | "short sha": func(m *Manifest) { |
| 726 | e := m.Entries["a"] |
| 727 | e.SHA256, e.Object = "aa", pfx+objectKey(e.Seq, "aa") |
| 728 | m.Entries["a"] = e |
| 729 | }, |
| 730 | "object elsewhere": func(m *Manifest) { |
| 731 | e := m.Entries["a"] |
| 732 | e.Object = "instances/other/files/g1/" + objectKey(e.Seq, e.SHA256) |
| 733 | m.Entries["a"] = e |
| 734 | }, |
| 735 | "object with another seq": func(m *Manifest) { |
| 736 | e := m.Entries["a"] |
| 737 | e.Object = pfx + objectKey(e.Seq+1, e.SHA256) |
| 738 | m.Entries["a"] = e |
| 739 | }, |
| 740 | "platform object elsewhere": func(m *Manifest) { |
| 741 | e := m.Platform["item"] |
| 742 | e.Object = "objects/elsewhere" |
| 743 | m.Platform["item"] = e |
| 744 | }, |
| 745 | "platform sha not hex": func(m *Manifest) { |
| 746 | e := m.Platform["item"] |
| 747 | e.SHA256 = strings.Repeat("g", 64) |
| 748 | e.Object = pfx + objectKey(e.Seq, e.SHA256) |
| 749 | m.Platform["item"] = e |
| 750 | }, |
| 751 | } |
| 752 | for name, bend := range cases { |
| 753 | s, dir := putHostile(t, bend) |
| 754 | if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil { |
| 755 | t.Fatalf("%s: Restore accepted the manifest", name) |
| 756 | } |
| 757 | if got := read(t, dir, "gone"); got != "still here" { |
| 758 | t.Fatalf("%s: gone = %q, want it untouched", name, got) |
| 759 | } |
| 760 | if _, err := os.Stat(filepath.Join(dir, "a")); !os.IsNotExist(err) { |
| 761 | t.Fatalf("%s: a was written", name) |
| 762 | } |
| 763 | } |
| 764 | } |
| 765 | |
| 766 | func TestReadPlatformRefusesAMalformedEntry(t *testing.T) { |
| 767 | s, _ := putHostile(t, func(m *Manifest) { |
| 768 | e := m.Platform["item"] |
| 769 | e.Object = pfx + "objects/elsewhere" |
| 770 | m.Platform["item"] = e |
| 771 | }) |
| 772 | ctx := context.Background() |
| 773 | // The right bytes at the wrong key: only the key check can refuse it. |
| 774 | if _, err := s.Put(ctx, pfx+"objects/elsewhere", []byte("plat"), ""); err != nil { |
| 775 | t.Fatal(err) |
| 776 | } |
| 777 | m, _, err := Load(ctx, s, pfx) |
| 778 | if err != nil { |
| 779 | t.Fatal(err) |
| 780 | } |
| 781 | if _, err := ReadPlatform(ctx, s, pfx, m, "item", nil); err == nil { |
| 782 | t.Fatal("ReadPlatform accepted an object key that is not its own") |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | func TestRestoreRefusesAnEntryWhoseSizeIsOutOfRangeAndTouchesNothing(t *testing.T) { |
| 787 | for _, size := range []int64{-1, MaxFileBytes + 1} { |
| 788 | s, dir := putHostile(t, func(m *Manifest) { |
| 789 | e := m.Entries["a"] |
| 790 | e.Size = size |
| 791 | m.Entries["a"] = e |
| 792 | }) |
| 793 | if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil { |
| 794 | t.Fatalf("size %d: Restore accepted the manifest", size) |
| 795 | } |
| 796 | if got := read(t, dir, "gone"); got != "still here" { |
| 797 | t.Fatalf("size %d: gone = %q, want it untouched", size, got) |
| 798 | } |
| 799 | } |
| 800 | } |
| 801 | |
| 802 | func TestFetchRefusesAnObjectWhoseLengthIsNotTheRecordedSize(t *testing.T) { |
| 803 | s, dir := putHostile(t, func(m *Manifest) { |
| 804 | e := m.Entries["a"] |
| 805 | e.Size = 3 // the object is four bytes, and its hash is right |
| 806 | m.Entries["a"] = e |
| 807 | p := m.Platform["item"] |
| 808 | p.Size = 5 |
| 809 | m.Platform["item"] = p |
| 810 | }) |
| 811 | ctx := context.Background() |
| 812 | if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil || !strings.Contains(err.Error(), "size") { |
| 813 | t.Fatalf("Restore err = %v, want a size mismatch", err) |
| 814 | } |
| 815 | m, _, err := Load(ctx, s, pfx) |
| 816 | if err != nil { |
| 817 | t.Fatal(err) |
| 818 | } |
| 819 | if _, err := ReadPlatform(ctx, s, pfx, m, "item", nil); err == nil || !strings.Contains(err.Error(), "size") { |
| 820 | t.Fatalf("ReadPlatform err = %v, want a size mismatch", err) |
| 821 | } |
| 822 | } |
| 823 | |