| 1 | package sessioncontent |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | ) |
| 15 | |
| 16 | func TestStorePutOpenAndDeduplicate(t *testing.T) { |
| 17 | t.Parallel() |
| 18 | root := t.TempDir() |
| 19 | store := New(root) |
| 20 | body := bytes.Repeat([]byte("reasonix-content\n"), 10000) |
| 21 | sum := sha256.Sum256(body) |
| 22 | wantDigest := hex.EncodeToString(sum[:]) |
| 23 | |
| 24 | first, err := store.Put(context.Background(), bytes.NewReader(body), Metadata{ |
| 25 | MediaType: "text/plain; charset=utf-8", |
| 26 | Name: "../../not-a-storage-path.txt", |
| 27 | }) |
| 28 | if err != nil { |
| 29 | t.Fatalf("Put first: %v", err) |
| 30 | } |
| 31 | second, err := store.Put(context.Background(), bytes.NewReader(body), Metadata{ |
| 32 | MediaType: "application/octet-stream", |
| 33 | Name: "different-display-name.bin", |
| 34 | }) |
| 35 | if err != nil { |
| 36 | t.Fatalf("Put second: %v", err) |
| 37 | } |
| 38 | if first.Digest != wantDigest || second.Digest != wantDigest { |
| 39 | t.Fatalf("digest = %q / %q, want %q", first.Digest, second.Digest, wantDigest) |
| 40 | } |
| 41 | if first.Bytes != int64(len(body)) || second.Bytes != int64(len(body)) { |
| 42 | t.Fatalf("bytes = %d / %d, want %d", first.Bytes, second.Bytes, len(body)) |
| 43 | } |
| 44 | if first.Name != "../../not-a-storage-path.txt" || second.Name != "different-display-name.bin" { |
| 45 | t.Fatalf("display metadata was not preserved: %#v / %#v", first, second) |
| 46 | } |
| 47 | |
| 48 | r, err := store.Open(context.Background(), first) |
| 49 | if err != nil { |
| 50 | t.Fatalf("Open: %v", err) |
| 51 | } |
| 52 | got, readErr := io.ReadAll(r) |
| 53 | closeErr := r.Close() |
| 54 | if readErr != nil || closeErr != nil { |
| 55 | t.Fatalf("read/close: %v / %v", readErr, closeErr) |
| 56 | } |
| 57 | if !bytes.Equal(got, body) { |
| 58 | t.Fatal("opened bytes differ from input") |
| 59 | } |
| 60 | |
| 61 | var objects int |
| 62 | err = filepath.WalkDir(filepath.Join(root, "objects"), func(_ string, entry os.DirEntry, walkErr error) error { |
| 63 | if walkErr != nil { |
| 64 | return walkErr |
| 65 | } |
| 66 | if !entry.IsDir() { |
| 67 | objects++ |
| 68 | } |
| 69 | return nil |
| 70 | }) |
| 71 | if err != nil { |
| 72 | t.Fatalf("walk objects: %v", err) |
| 73 | } |
| 74 | if objects != 1 { |
| 75 | t.Fatalf("physical objects = %d, want 1", objects) |
| 76 | } |
| 77 | if _, err := os.Stat(filepath.Join(root, "not-a-storage-path.txt")); !os.IsNotExist(err) { |
| 78 | t.Fatalf("display name escaped content store: %v", err) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestStoreReadRangeAndStat(t *testing.T) { |
| 83 | t.Parallel() |
| 84 | store := New(t.TempDir()) |
| 85 | body := []byte("0123456789") |
| 86 | ref, err := store.Put(context.Background(), bytes.NewReader(body), Metadata{MediaType: "text/plain"}) |
| 87 | if err != nil { |
| 88 | t.Fatalf("Put: %v", err) |
| 89 | } |
| 90 | got, err := store.ReadRange(context.Background(), ref, 3, 4) |
| 91 | if err != nil { |
| 92 | t.Fatalf("ReadRange: %v", err) |
| 93 | } |
| 94 | if string(got) != "3456" { |
| 95 | t.Fatalf("range = %q, want 3456", got) |
| 96 | } |
| 97 | stat, err := store.Stat(context.Background(), ref) |
| 98 | if err != nil { |
| 99 | t.Fatalf("Stat: %v", err) |
| 100 | } |
| 101 | if stat.Digest != ref.Digest || stat.Bytes != int64(len(body)) { |
| 102 | t.Fatalf("Stat = %#v", stat) |
| 103 | } |
| 104 | if _, err := store.ReadRange(context.Background(), ref, -1, 1); err == nil { |
| 105 | t.Fatal("negative range offset was accepted") |
| 106 | } |
| 107 | if _, err := store.ReadRange(context.Background(), ref, 9, 2); err == nil { |
| 108 | t.Fatal("out-of-bounds range was accepted") |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestStoreRejectsUntrustedIntegrityIndexPath(t *testing.T) { |
| 113 | t.Parallel() |
| 114 | store := New(t.TempDir()) |
| 115 | ref := Ref{Digest: "../../outside", Bytes: 1, IndexDigest: strings.Repeat("0", sha256.Size*2), IntegrityBlock: IntegrityBlockBytes} |
| 116 | if _, err := store.readIndex(t.Context(), ref); err == nil { |
| 117 | t.Fatal("readIndex accepted an untrusted digest path") |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestStoreReadRangeVerifiesOnlyTouchedIntegrityBlocks(t *testing.T) { |
| 122 | t.Parallel() |
| 123 | store := New(t.TempDir()) |
| 124 | body := bytes.Repeat([]byte("a"), 3*IntegrityBlockBytes) |
| 125 | ref, err := store.Put(t.Context(), bytes.NewReader(body), Metadata{}) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | object, err := os.OpenFile(store.objectPath(ref.Digest), os.O_RDWR, 0o600) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if _, err := object.WriteAt([]byte("x"), 16); err != nil { |
| 134 | _ = object.Close() |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if err := object.Close(); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if got, err := store.ReadRange(t.Context(), ref, 2*IntegrityBlockBytes, 32); err != nil || !bytes.Equal(got, body[2*IntegrityBlockBytes:2*IntegrityBlockBytes+32]) { |
| 141 | t.Fatalf("untouched range = %d bytes, err=%v", len(got), err) |
| 142 | } |
| 143 | if _, err := store.ReadRange(t.Context(), ref, 0, 32); err == nil { |
| 144 | t.Fatal("tampered range passed block verification") |
| 145 | } |
| 146 | if err := store.Verify(t.Context(), ref); err == nil { |
| 147 | t.Fatal("full verification accepted tampered object") |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestStoreRejectsCancellationAndTampering(t *testing.T) { |
| 152 | t.Parallel() |
| 153 | root := t.TempDir() |
| 154 | store := New(root) |
| 155 | canceled, cancel := context.WithCancel(context.Background()) |
| 156 | cancel() |
| 157 | if _, err := store.Put(canceled, bytes.NewReader([]byte("never published")), Metadata{}); err == nil { |
| 158 | t.Fatal("Put with canceled context succeeded") |
| 159 | } |
| 160 | |
| 161 | ref, err := store.Put(context.Background(), bytes.NewReader([]byte("original")), Metadata{}) |
| 162 | if err != nil { |
| 163 | t.Fatalf("Put: %v", err) |
| 164 | } |
| 165 | if err := os.WriteFile(store.objectPath(ref.Digest), []byte("tampered"), 0o600); err != nil { |
| 166 | t.Fatalf("tamper object: %v", err) |
| 167 | } |
| 168 | if _, err := store.Open(context.Background(), ref); err == nil { |
| 169 | t.Fatal("Open accepted a tampered object") |
| 170 | } |
| 171 | if _, err := store.Stat(context.Background(), ref); err == nil { |
| 172 | t.Fatal("Stat accepted a tampered object") |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestStoreConcurrentPutSameContent(t *testing.T) { |
| 177 | t.Parallel() |
| 178 | store := New(t.TempDir()) |
| 179 | body := bytes.Repeat([]byte("same"), 250000) |
| 180 | const workers = 8 |
| 181 | refs := make([]Ref, workers) |
| 182 | errs := make([]error, workers) |
| 183 | var wg sync.WaitGroup |
| 184 | for i := range workers { |
| 185 | wg.Go(func() { |
| 186 | refs[i], errs[i] = store.Put(context.Background(), bytes.NewReader(body), Metadata{}) |
| 187 | }) |
| 188 | } |
| 189 | wg.Wait() |
| 190 | for i := range workers { |
| 191 | if errs[i] != nil { |
| 192 | t.Fatalf("Put[%d]: %v", i, errs[i]) |
| 193 | } |
| 194 | if refs[i].Digest != refs[0].Digest { |
| 195 | t.Fatalf("digest[%d] = %q, want %q", i, refs[i].Digest, refs[0].Digest) |
| 196 | } |
| 197 | } |
| 198 | r, err := store.Open(context.Background(), refs[0]) |
| 199 | if err != nil { |
| 200 | t.Fatalf("Open: %v", err) |
| 201 | } |
| 202 | defer r.Close() |
| 203 | got, err := io.ReadAll(r) |
| 204 | if err != nil || !bytes.Equal(got, body) { |
| 205 | t.Fatalf("published object invalid: err=%v bytes=%d", err, len(got)) |
| 206 | } |
| 207 | } |
| 208 |