| 1 | package hostfiles |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // writeFile is sync_test.go's write, which is unix-only. |
| 16 | func writeFile(t *testing.T, dir, rel, body string) { |
| 17 | t.Helper() |
| 18 | full := filepath.Join(dir, rel) |
| 19 | if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | if err := os.WriteFile(full, []byte(body), 0o640); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // pfx2 is the next generation of pfx's incarnation. |
| 28 | const pfx2 = "instances/shop.test/abc/files/g2/" |
| 29 | |
| 30 | // publishG1 publishes two files and a platform item under pfx. |
| 31 | func publishG1(t *testing.T, s release.Store) { |
| 32 | t.Helper() |
| 33 | p := open(t, s) |
| 34 | stage(t, p, "instance.key", "k1") |
| 35 | stage(t, p, "uploads/a/b.txt", "hello") |
| 36 | if err := p.StagePlatform(context.Background(), "env", []byte("A=1")); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | commit(t, p) |
| 40 | } |
| 41 | |
| 42 | func TestEntriesCarryTheirOriginQualifiedObjectKey(t *testing.T) { |
| 43 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 44 | publishG1(t, s) |
| 45 | m, _, err := Load(ctx, s, pfx) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if m.V != 2 { |
| 50 | t.Fatalf("manifest version = %d, want 2", m.V) |
| 51 | } |
| 52 | e := m.Entries["instance.key"] |
| 53 | if want := pfx + objectKey(e.Seq, e.SHA256); e.Object != want { |
| 54 | t.Fatalf("object = %q, want %q", e.Object, want) |
| 55 | } |
| 56 | if _, _, err := s.Get(ctx, e.Object); err != nil { |
| 57 | t.Fatalf("the object is not at its key: %v", err) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestACopiedManifestRestoresTheSameBytesWithoutCopyingObjects(t *testing.T) { |
| 62 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 63 | publishG1(t, s) |
| 64 | m, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil) |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if keys, _ := s.List(ctx, pfx2+"objects/"); len(keys) != 0 { |
| 69 | t.Fatalf("the copy wrote objects: %v", keys) |
| 70 | } |
| 71 | src, _, _ := Load(ctx, s, pfx) |
| 72 | if m.Seq != src.Seq || len(m.Entries) != len(src.Entries) { |
| 73 | t.Fatalf("copy = %+v, source = %+v", m, src) |
| 74 | } |
| 75 | dir := t.TempDir() |
| 76 | if _, _, err := Restore(ctx, s, pfx2, dir, []string{pfx, pfx2}); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | if read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a/b.txt") != "hello" { |
| 80 | t.Fatal("restored the wrong bytes") |
| 81 | } |
| 82 | got, err := ReadPlatform(ctx, s, pfx2, m, "env", []string{pfx, pfx2}) |
| 83 | if err != nil || string(got) != "A=1" { |
| 84 | t.Fatalf("ReadPlatform = %q, %v", got, err) |
| 85 | } |
| 86 | |
| 87 | // The new generation's publisher continues the sequence. A changed file |
| 88 | // is a new object under g2, an unchanged one keeps its g1 reference. |
| 89 | p, err := Open(ctx, s, pfx2) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | stage(t, p, "instance.key", "k1") |
| 94 | stage(t, p, "uploads/a/b.txt", "changed") |
| 95 | if seq := commit(t, p); seq != src.Seq+1 { |
| 96 | t.Fatalf("seq = %d, want %d", seq, src.Seq+1) |
| 97 | } |
| 98 | m2, _, _ := Load(ctx, s, pfx2) |
| 99 | if !strings.HasPrefix(m2.Entries["instance.key"].Object, pfx) || !strings.HasPrefix(m2.Entries["uploads/a/b.txt"].Object, pfx2) { |
| 100 | t.Fatalf("entries = %+v", m2.Entries) |
| 101 | } |
| 102 | dir2 := t.TempDir() |
| 103 | if _, _, err := Restore(ctx, s, pfx2, dir2, []string{pfx, pfx2}); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if read(t, dir2, "instance.key") != "k1" || read(t, dir2, "uploads/a/b.txt") != "changed" { |
| 107 | t.Fatal("restored the wrong bytes after the second generation published") |
| 108 | } |
| 109 | // The source generation is untouched. |
| 110 | if again, _, _ := Load(ctx, s, pfx); again.Seq != src.Seq { |
| 111 | t.Fatalf("source seq moved to %d", again.Seq) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestRestoreRefusesAnEntryOutsideTheAllowedOriginsAndTouchesNothing(t *testing.T) { |
| 116 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 117 | publishG1(t, s) |
| 118 | m, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | for name, origins := range map[string][]string{ |
| 123 | "default (own prefix only)": nil, |
| 124 | "another generation only": {"instances/shop.test/abc/files/g0/", pfx2}, |
| 125 | } { |
| 126 | dir := t.TempDir() |
| 127 | if err := os.WriteFile(filepath.Join(dir, "instance.key"), []byte("before"), 0o600); err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | if _, _, err := Restore(ctx, s, pfx2, dir, origins); err == nil || !strings.Contains(err.Error(), "origin") { |
| 131 | t.Fatalf("%s: Restore err = %v, want an origin refusal", name, err) |
| 132 | } |
| 133 | if read(t, dir, "instance.key") != "before" { |
| 134 | t.Fatalf("%s: Restore wrote before refusing", name) |
| 135 | } |
| 136 | if _, err := os.Stat(filepath.Join(dir, "uploads")); !os.IsNotExist(err) { |
| 137 | t.Fatalf("%s: Restore created uploads/", name) |
| 138 | } |
| 139 | if _, err := ReadPlatform(ctx, s, pfx2, m, "env", origins); err == nil { |
| 140 | t.Fatalf("%s: ReadPlatform read an object outside its origins", name) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestRestoreRefusesATamperedObjectKey(t *testing.T) { |
| 146 | rel := func(e Entry) string { return objectKey(e.Seq, e.SHA256) } |
| 147 | cases := map[string]func(e Entry) string{ |
| 148 | "wrong seq": func(e Entry) string { return pfx + objectKey(e.Seq+1, e.SHA256) }, |
| 149 | "wrong sha": func(e Entry) string { return pfx + objectKey(e.Seq, strings.Repeat("0", 64)) }, |
| 150 | "relative (version 1)": rel, |
| 151 | "dot-dot to a sibling gen": func(e Entry) string { return pfx + "../g9/" + rel(e) }, |
| 152 | "dot-dot inside": func(e Entry) string { return pfx + "objects/../" + rel(e) }, |
| 153 | "absolute": func(e Entry) string { return "/" + pfx + rel(e) }, |
| 154 | "another host's prefix": func(e Entry) string { return "instances/other.test/abc/files/g1/" + rel(e) }, |
| 155 | "nested under the origin": func(e Entry) string { return pfx + "x/" + rel(e) }, |
| 156 | "double slash": func(e Entry) string { return strings.TrimSuffix(pfx, "/") + "//" + rel(e) }, |
| 157 | "empty": func(Entry) string { return "" }, |
| 158 | } |
| 159 | for name, key := range cases { |
| 160 | for _, platform := range []bool{false, true} { |
| 161 | s, dir := putHostile(t, func(m *Manifest) { |
| 162 | if platform { |
| 163 | e := m.Platform["item"] |
| 164 | e.Object = key(e) |
| 165 | m.Platform["item"] = e |
| 166 | return |
| 167 | } |
| 168 | e := m.Entries["a"] |
| 169 | e.Object = key(e) |
| 170 | m.Entries["a"] = e |
| 171 | }) |
| 172 | if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil { |
| 173 | t.Fatalf("%s (platform %v): Restore accepted the manifest", name, platform) |
| 174 | } |
| 175 | if got := read(t, dir, "gone"); got != "still here" { |
| 176 | t.Fatalf("%s (platform %v): gone = %q, want it untouched", name, platform, got) |
| 177 | } |
| 178 | if _, err := os.Stat(filepath.Join(dir, "a")); !os.IsNotExist(err) { |
| 179 | t.Fatalf("%s (platform %v): a was written", name, platform) |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | func TestRestoreRefusesAMalformedOrigin(t *testing.T) { |
| 186 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 187 | publishG1(t, s) |
| 188 | for _, o := range []string{"", "/", "instances/shop.test/abc/files/g1", "/instances/shop.test/abc/files/g1/", |
| 189 | "instances/shop.test/abc/files/../files/g1/", "instances//shop.test/", "./instances/"} { |
| 190 | if _, _, err := Restore(ctx, s, pfx, t.TempDir(), []string{o}); err == nil { |
| 191 | t.Fatalf("Restore accepted origin %q", o) |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestAManifestOfAnotherVersionIsRefused(t *testing.T) { |
| 197 | for _, v := range []int{0, 1, 3} { |
| 198 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 199 | body, _ := json.Marshal(map[string]any{"v": v, "seq": 0, "entries": map[string]any{}, "platform": map[string]any{}}) |
| 200 | if _, err := s.Put(ctx, ManifestKey(pfx), body, ""); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if _, _, err := Load(ctx, s, pfx); err == nil || !strings.Contains(err.Error(), "version") { |
| 204 | t.Fatalf("v%d: Load err = %v", v, err) |
| 205 | } |
| 206 | if _, err := Open(ctx, s, pfx); err == nil { |
| 207 | t.Fatalf("v%d: Open accepted it", v) |
| 208 | } |
| 209 | if _, _, err := Restore(ctx, s, pfx, t.TempDir(), nil); err == nil { |
| 210 | t.Fatalf("v%d: Restore accepted it", v) |
| 211 | } |
| 212 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err == nil { |
| 213 | t.Fatalf("v%d: CopyManifest accepted it", v) |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestCopyManifestIsCreateOnlyAndRetrySafe(t *testing.T) { |
| 219 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 220 | publishG1(t, s) |
| 221 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | // A repeat whose first response was lost finds its own bytes: success. |
| 225 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil { |
| 226 | t.Fatalf("a repeated copy: %v", err) |
| 227 | } |
| 228 | // Once g2 has moved on, a copy is refused and changes nothing. |
| 229 | p, err := Open(ctx, s, pfx2) |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | stage(t, p, "new", "n") |
| 234 | commit(t, p) |
| 235 | before, _, _ := s.Get(ctx, ManifestKey(pfx2)) |
| 236 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); !errors.Is(err, ErrManifestExists) { |
| 237 | t.Fatalf("err = %v, want ErrManifestExists", err) |
| 238 | } |
| 239 | after, _, _ := s.Get(ctx, ManifestKey(pfx2)) |
| 240 | if string(before) != string(after) { |
| 241 | t.Fatal("a refused copy changed the target") |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | func TestCopyManifestRefusesAMissingOrInvalidSourceAndWritesNothing(t *testing.T) { |
| 246 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 247 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); !errors.Is(err, os.ErrNotExist) { |
| 248 | t.Fatalf("absent source: err = %v, want os.ErrNotExist", err) |
| 249 | } |
| 250 | // g2 copied from g1 references g1; copying g2 on to g3 with only g2 |
| 251 | // admitted is refused. |
| 252 | publishG1(t, s) |
| 253 | if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | const pfx3 = "instances/shop.test/abc/files/g3/" |
| 257 | if _, err := CopyManifest(ctx, s, pfx2, pfx3, 0, nil); err == nil || !strings.Contains(err.Error(), "origin") { |
| 258 | t.Fatalf("err = %v, want an origin refusal", err) |
| 259 | } |
| 260 | if _, _, err := s.Get(ctx, ManifestKey(pfx3)); !errors.Is(err, os.ErrNotExist) { |
| 261 | t.Fatalf("a refused copy wrote a manifest: %v", err) |
| 262 | } |
| 263 | if _, err := CopyManifest(ctx, s, pfx2, pfx3, 0, []string{pfx, pfx2}); err != nil { |
| 264 | t.Fatalf("with g1 admitted: %v", err) |
| 265 | } |
| 266 | for _, bad := range [][2]string{{pfx, pfx}, {pfx, "instances/shop.test/abc/files/g4"}, {"nope", pfx2}} { |
| 267 | if _, err := CopyManifest(ctx, s, bad[0], bad[1], 0, nil); err == nil { |
| 268 | t.Fatalf("CopyManifest(%q, %q) accepted", bad[0], bad[1]) |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestPublishCommitsTheDirectoryAsOneRevision(t *testing.T) { |
| 274 | ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir() |
| 275 | writeFile(t, src, "instance.key", "k1") |
| 276 | writeFile(t, src, "uploads/a.txt", "a") |
| 277 | writeFile(t, src, "skip/me", "x") |
| 278 | skip := func(rel string, isDir bool) bool { return rel == "skip" } |
| 279 | seq, st, err := Publish(ctx, s, pfx, src, skip) |
| 280 | if err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | if seq != 2 || st.Published != 2 { |
| 284 | t.Fatalf("seq = %d, stats = %+v", seq, st) |
| 285 | } |
| 286 | dir := t.TempDir() |
| 287 | m, _, err := Restore(ctx, s, pfx, dir, nil) |
| 288 | if err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | if m.Seq != seq || read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a.txt") != "a" { |
| 292 | t.Fatalf("m = %+v", m) |
| 293 | } |
| 294 | if _, ok := m.Entries["skip/me"]; ok { |
| 295 | t.Fatal("a skipped path was published") |
| 296 | } |
| 297 | // Publishing again with nothing changed is the same revision. |
| 298 | if again, _, err := Publish(ctx, s, pfx, src, skip); err != nil || again != seq { |
| 299 | t.Fatalf("again = %d, %v", again, err) |
| 300 | } |
| 301 | if _, _, err := Publish(ctx, s, pfx, filepath.Join(src, "absent"), nil); err == nil { |
| 302 | t.Fatal("published a missing directory") |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // expectSeq pins the copy to the source revision the caller admitted: a |
| 307 | // source that has moved on is refused, on a first attempt or a retry, and |
| 308 | // nothing is written. |
| 309 | func TestCopyManifestRefusesASourceThatIsNotAtTheExpectedSeq(t *testing.T) { |
| 310 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 311 | publishG1(t, s) |
| 312 | src, _, _ := Load(ctx, s, pfx) |
| 313 | if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq+1, nil); !errors.Is(err, ErrSourceMoved) { |
| 314 | t.Fatalf("err = %v, want ErrSourceMoved", err) |
| 315 | } |
| 316 | if _, _, err := s.Get(ctx, ManifestKey(pfx2)); !errors.Is(err, os.ErrNotExist) { |
| 317 | t.Fatalf("a refused copy wrote a manifest: %v", err) |
| 318 | } |
| 319 | m, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil) |
| 320 | if err != nil || m.Seq != src.Seq { |
| 321 | t.Fatalf("m.Seq = %d, err = %v", m.Seq, err) |
| 322 | } |
| 323 | if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil); err != nil { |
| 324 | t.Fatalf("a repeat at the same seq: %v", err) |
| 325 | } |
| 326 | // The source commits again: a retry pinned to the old seq is refused, |
| 327 | // and the target keeps the pinned copy. |
| 328 | p := open(t, s) |
| 329 | stage(t, p, "later", "x") |
| 330 | commit(t, p) |
| 331 | before, _, _ := s.Get(ctx, ManifestKey(pfx2)) |
| 332 | if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil); !errors.Is(err, ErrSourceMoved) { |
| 333 | t.Fatalf("err = %v, want ErrSourceMoved", err) |
| 334 | } |
| 335 | if after, _, _ := s.Get(ctx, ManifestKey(pfx2)); string(before) != string(after) { |
| 336 | t.Fatal("a refused copy changed the target") |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | func TestRestoreAndReadPlatformRefuseAMalformedPrefix(t *testing.T) { |
| 341 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 342 | publishG1(t, s) |
| 343 | m, _, _ := Load(ctx, s, pfx) |
| 344 | for _, bad := range []string{"", "instances/shop.test/abc/files/g1", "/" + pfx, "instances/../" + pfx, "instances//x/"} { |
| 345 | // Admitting pfx explicitly means only the prefix check can refuse. |
| 346 | if _, _, err := Restore(ctx, s, bad, t.TempDir(), []string{pfx}); err == nil { |
| 347 | t.Fatalf("Restore accepted prefix %q", bad) |
| 348 | } |
| 349 | if _, err := ReadPlatform(ctx, s, bad, m, "env", []string{pfx}); err == nil { |
| 350 | t.Fatalf("ReadPlatform accepted prefix %q", bad) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |