fix(snapshot): persist legacy import cleanup
- commit
- 97e11ed4881eccaf6bb3b8aa0e018fb0bd6cb185
- author
- brice · · 2026-07-22 04:10 UTC
- parent
- 48f90b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +404 -223
M internal/adapters/snapshot/legacy.go +89 -30
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package snapshot | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 5 | 7 | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "io" | |
| @@ -14,13 +16,14 @@ import ( | |||
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | const ( | |
| 17 | - | legacyDeleteMarkerPrefix = ".legacy-delete-" | |
| 19 | + | legacyDeleteMarkerPrefix = ".legacy-import-receipt-" | |
| 18 | 20 | maxLegacyDeleteMarkers = maxLegacySnapshotFiles | |
| 19 | 21 | maxLegacyDeleteNameBytes = 200 | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 22 | 24 | type legacyDeleteMarker struct { | |
| 23 | - | name string | |
| 25 | + | name string | |
| 26 | + | digest [sha256.Size]byte | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | type legacyDirectory interface { | |
| @@ -116,68 +119,114 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||
| 116 | 119 | return out, nil | |
| 117 | 120 | } | |
| 118 | 121 | ||
| 119 | - | // DeleteLegacy durably authorizes deletion before unlinking the legacy source. | |
| 120 | - | // The private marker survives process restart, so LoadLegacy retries an | |
| 121 | - | // authorized deletion rather than returning it for a second import. | |
| 122 | - | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |
| 122 | + | // DeleteVerifiedLegacy records a hash-bound import receipt before deleting the | |
| 123 | + | // source. LoadLegacy consumes surviving receipts after restart, but only when | |
| 124 | + | // the source still has the exact bytes that were verified and published. | |
| 125 | + | func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 123 | 126 | if err := ctx.Err(); err != nil { | |
| 124 | 127 | return err | |
| 125 | 128 | } | |
| 126 | - | filename := filenameForName(name) | |
| 129 | + | filename := filenameForName(blob.Name) | |
| 127 | 130 | lock := r.lockSession("legacy-sync:" + filename) | |
| 128 | 131 | defer r.unlockSession(lock) | |
| 129 | 132 | if err := ctx.Err(); err != nil { | |
| 130 | 133 | return err | |
| 131 | 134 | } | |
| 132 | - | if err := r.authorizeLegacyDelete(name); err != nil { | |
| 135 | + | receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)} | |
| 136 | + | if err := r.authorizeLegacyDelete(receipt); err != nil { | |
| 133 | 137 | return err | |
| 134 | 138 | } | |
| 135 | - | return r.deleteAuthorizedLegacy(ctx, name, filename) | |
| 139 | + | return r.deleteAuthorizedLegacy(ctx, filename, receipt) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // DeleteLegacy is for deliberate session purges, which do not represent a | |
| 143 | + | // verified import and therefore must not create an import receipt. | |
| 144 | + | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |
| 145 | + | if err := ctx.Err(); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | filename := filenameForName(name) | |
| 149 | + | lock := r.lockSession("legacy-sync:" + filename) | |
| 150 | + | defer r.unlockSession(lock) | |
| 151 | + | return r.deleteLegacyFile(ctx, filename) | |
| 136 | 152 | } | |
| 137 | 153 | ||
| 138 | 154 | func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key } | |
| 139 | 155 | ||
| 140 | - | func (r *Repository) authorizeLegacyDelete(name string) error { | |
| 156 | + | func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error { | |
| 141 | 157 | if err := r.ensurePrivateDirectory(r.dir); err != nil { | |
| 142 | - | return fmt.Errorf("create legacy deletion marker directory: %w", safeFilesystemError(err)) | |
| 158 | + | return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err)) | |
| 143 | 159 | } | |
| 144 | - | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | |
| 145 | - | if err := r.writeImmutable(path, []byte(name), func(data []byte) error { | |
| 146 | - | if string(data) != name { | |
| 147 | - | return fmt.Errorf("legacy deletion marker identity mismatch") | |
| 160 | + | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name))) | |
| 161 | + | data := append(receipt.digest[:], receipt.name...) | |
| 162 | + | if err := r.writeImmutable(path, data, func(data []byte) error { | |
| 163 | + | if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) { | |
| 164 | + | return fmt.Errorf("legacy import receipt identity mismatch") | |
| 148 | 165 | } | |
| 149 | 166 | return nil | |
| 150 | 167 | }); err != nil { | |
| 151 | - | return fmt.Errorf("write legacy deletion marker: %w", safeFilesystemError(err)) | |
| 168 | + | return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err)) | |
| 152 | 169 | } | |
| 153 | 170 | return nil | |
| 154 | 171 | } | |
| 155 | 172 | ||
| 156 | - | func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, name, filename string) error { | |
| 173 | + | // deleteAuthorizedLegacy deletes only a source whose bytes match the durable | |
| 174 | + | // receipt. A replaced source invalidates the receipt and remains importable. | |
| 175 | + | func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error { | |
| 157 | 176 | if _, pending := r.pendingLegacySync.Load(filename); pending { | |
| 158 | 177 | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 159 | 178 | return err | |
| 160 | 179 | } | |
| 161 | - | return r.clearLegacyDeleteAuthorization(name) | |
| 180 | + | return r.clearLegacyDeleteAuthorization(receipt.name) | |
| 162 | 181 | } | |
| 163 | 182 | path := filepath.Join(r.dir, filename) | |
| 164 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 165 | - | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |
| 183 | + | data, err := r.readBounded(path) | |
| 184 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 185 | + | return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err)) | |
| 166 | 186 | } | |
| 167 | - | r.pendingLegacySync.Store(filename, struct{}{}) | |
| 168 | - | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 169 | - | return err | |
| 187 | + | if err == nil && sha256.Sum256(data) != receipt.digest { | |
| 188 | + | return r.clearLegacyDeleteAuthorization(receipt.name) | |
| 170 | 189 | } | |
| 171 | - | return r.clearLegacyDeleteAuthorization(name) | |
| 190 | + | return r.deleteLegacyFile(ctx, filename, receipt.name) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error { | |
| 194 | + | if _, pending := r.pendingLegacySync.Load(filename); pending { | |
| 195 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 196 | + | return err | |
| 197 | + | } | |
| 198 | + | } else { | |
| 199 | + | path := filepath.Join(r.dir, filename) | |
| 200 | + | if err := r.remove(path); err != nil { | |
| 201 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 202 | + | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |
| 203 | + | } | |
| 204 | + | // A missing repository has no deletion to make durable. This preserves | |
| 205 | + | // idempotence for explicit purges before the snapshot root exists. | |
| 206 | + | if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) { | |
| 207 | + | return nil | |
| 208 | + | } else if statErr != nil { | |
| 209 | + | return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr)) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | r.pendingLegacySync.Store(filename, struct{}{}) | |
| 213 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |
| 214 | + | return err | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | if len(clearReceipt) != 0 { | |
| 218 | + | return r.clearLegacyDeleteAuthorization(clearReceipt[0]) | |
| 219 | + | } | |
| 220 | + | return nil | |
| 172 | 221 | } | |
| 173 | 222 | ||
| 174 | 223 | func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | |
| 175 | 224 | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | |
| 176 | 225 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 177 | - | return fmt.Errorf("clear legacy deletion marker: %w", safeFilesystemError(err)) | |
| 226 | + | return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err)) | |
| 178 | 227 | } | |
| 179 | 228 | if err := r.syncDirectory(r.dir); err != nil { | |
| 180 | - | return fmt.Errorf("sync legacy deletion marker directory: %w", safeFilesystemError(err)) | |
| 229 | + | return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err)) | |
| 181 | 230 | } | |
| 182 | 231 | return nil | |
| 183 | 232 | } | |
| @@ -191,7 +240,11 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *in | |||
| 191 | 240 | return err | |
| 192 | 241 | } | |
| 193 | 242 | for _, marker := range markers { | |
| 194 | - | if err := r.DeleteLegacy(ctx, marker.name); err != nil { | |
| 243 | + | filename := filenameForName(marker.name) | |
| 244 | + | lock := r.lockSession("legacy-sync:" + filename) | |
| 245 | + | err := r.deleteAuthorizedLegacy(ctx, filename, marker) | |
| 246 | + | r.unlockSession(lock) | |
| 247 | + | if err != nil { | |
| 195 | 248 | return fmt.Errorf("retry authorized legacy deletion: %w", err) | |
| 196 | 249 | } | |
| 197 | 250 | } | |
| @@ -231,10 +284,16 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int | |||
| 231 | 284 | } | |
| 232 | 285 | path := filepath.Join(r.dir, entry.Name()) | |
| 233 | 286 | data, dataErr := r.readBounded(path) | |
| 234 | - | if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key { | |
| 235 | - | return nil, fmt.Errorf("invalid legacy deletion marker") | |
| 287 | + | if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes { | |
| 288 | + | return nil, fmt.Errorf("invalid legacy import receipt") | |
| 289 | + | } | |
| 290 | + | name := string(data[sha256.Size:]) | |
| 291 | + | if sessionKey(name) != key { | |
| 292 | + | return nil, fmt.Errorf("invalid legacy import receipt") | |
| 236 | 293 | } | |
| 237 | - | markers = append(markers, legacyDeleteMarker{name: string(data)}) | |
| 294 | + | marker := legacyDeleteMarker{name: name} | |
| 295 | + | copy(marker.digest[:], data[:sha256.Size]) | |
| 296 | + | markers = append(markers, marker) | |
| 238 | 297 | } | |
| 239 | 298 | if errors.Is(readErr, io.EOF) { | |
| 240 | 299 | break | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package snapshot | 1 | package snapshot | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "io" | 9 | "io" | ||
| @@ -14,13 +16,14 @@ import ( | |||||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | const ( | 18 | const ( | ||
| 17 | - | legacyDeleteMarkerPrefix = ".legacy-delete-" | 19 | + | legacyDeleteMarkerPrefix = ".legacy-import-receipt-" |
| 18 | maxLegacyDeleteMarkers = maxLegacySnapshotFiles | 20 | maxLegacyDeleteMarkers = maxLegacySnapshotFiles | ||
| 19 | maxLegacyDeleteNameBytes = 200 | 21 | maxLegacyDeleteNameBytes = 200 | ||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 22 | type legacyDeleteMarker struct { | 24 | type legacyDeleteMarker struct { | ||
| 23 | - | name string | 25 | + | name string |
| 26 | + | digest [sha256.Size]byte | |||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | type legacyDirectory interface { | 29 | type legacyDirectory interface { | ||
| @@ -116,68 +119,114 @@ func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot | |||||
| 116 | return out, nil | 119 | return out, nil | ||
| 117 | } | 120 | } | ||
| 118 | 121 | ||||
| 119 | - | // DeleteLegacy durably authorizes deletion before unlinking the legacy source. | 122 | + | // DeleteVerifiedLegacy records a hash-bound import receipt before deleting the |
| 120 | - | // The private marker survives process restart, so LoadLegacy retries an | 123 | + | // source. LoadLegacy consumes surviving receipts after restart, but only when |
| 121 | - | // authorized deletion rather than returning it for a second import. | 124 | + | // the source still has the exact bytes that were verified and published. |
| 122 | - | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | 125 | + | func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { |
| 123 | if err := ctx.Err(); err != nil { | 126 | if err := ctx.Err(); err != nil { | ||
| 124 | return err | 127 | return err | ||
| 125 | } | 128 | } | ||
| 126 | - | filename := filenameForName(name) | 129 | + | filename := filenameForName(blob.Name) |
| 127 | lock := r.lockSession("legacy-sync:" + filename) | 130 | lock := r.lockSession("legacy-sync:" + filename) | ||
| 128 | defer r.unlockSession(lock) | 131 | defer r.unlockSession(lock) | ||
| 129 | if err := ctx.Err(); err != nil { | 132 | if err := ctx.Err(); err != nil { | ||
| 130 | return err | 133 | return err | ||
| 131 | } | 134 | } | ||
| 132 | - | if err := r.authorizeLegacyDelete(name); err != nil { | 135 | + | receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)} |
| 136 | + | if err := r.authorizeLegacyDelete(receipt); err != nil { | |||
| 133 | return err | 137 | return err | ||
| 134 | } | 138 | } | ||
| 135 | - | return r.deleteAuthorizedLegacy(ctx, name, filename) | 139 | + | return r.deleteAuthorizedLegacy(ctx, filename, receipt) |
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // DeleteLegacy is for deliberate session purges, which do not represent a | |||
| 143 | + | // verified import and therefore must not create an import receipt. | |||
| 144 | + | func (r *Repository) DeleteLegacy(ctx context.Context, name string) error { | |||
| 145 | + | if err := ctx.Err(); err != nil { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | filename := filenameForName(name) | |||
| 149 | + | lock := r.lockSession("legacy-sync:" + filename) | |||
| 150 | + | defer r.unlockSession(lock) | |||
| 151 | + | return r.deleteLegacyFile(ctx, filename) | |||
| 136 | } | 152 | } | ||
| 137 | 153 | ||||
| 138 | func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key } | 154 | func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key } | ||
| 139 | 155 | ||||
| 140 | - | func (r *Repository) authorizeLegacyDelete(name string) error { | 156 | + | func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error { |
| 141 | if err := r.ensurePrivateDirectory(r.dir); err != nil { | 157 | if err := r.ensurePrivateDirectory(r.dir); err != nil { | ||
| 142 | - | return fmt.Errorf("create legacy deletion marker directory: %w", safeFilesystemError(err)) | 158 | + | return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err)) |
| 143 | } | 159 | } | ||
| 144 | - | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | 160 | + | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name))) |
| 145 | - | if err := r.writeImmutable(path, []byte(name), func(data []byte) error { | 161 | + | data := append(receipt.digest[:], receipt.name...) |
| 146 | - | if string(data) != name { | 162 | + | if err := r.writeImmutable(path, data, func(data []byte) error { |
| 147 | - | return fmt.Errorf("legacy deletion marker identity mismatch") | 163 | + | if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) { |
| 164 | + | return fmt.Errorf("legacy import receipt identity mismatch") | |||
| 148 | } | 165 | } | ||
| 149 | return nil | 166 | return nil | ||
| 150 | }); err != nil { | 167 | }); err != nil { | ||
| 151 | - | return fmt.Errorf("write legacy deletion marker: %w", safeFilesystemError(err)) | 168 | + | return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err)) |
| 152 | } | 169 | } | ||
| 153 | return nil | 170 | return nil | ||
| 154 | } | 171 | } | ||
| 155 | 172 | ||||
| 156 | - | func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, name, filename string) error { | 173 | + | // deleteAuthorizedLegacy deletes only a source whose bytes match the durable |
| 174 | + | // receipt. A replaced source invalidates the receipt and remains importable. | |||
| 175 | + | func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error { | |||
| 157 | if _, pending := r.pendingLegacySync.Load(filename); pending { | 176 | if _, pending := r.pendingLegacySync.Load(filename); pending { | ||
| 158 | if err := r.syncPendingLegacy(ctx, filename); err != nil { | 177 | if err := r.syncPendingLegacy(ctx, filename); err != nil { | ||
| 159 | return err | 178 | return err | ||
| 160 | } | 179 | } | ||
| 161 | - | return r.clearLegacyDeleteAuthorization(name) | 180 | + | return r.clearLegacyDeleteAuthorization(receipt.name) |
| 162 | } | 181 | } | ||
| 163 | path := filepath.Join(r.dir, filename) | 182 | path := filepath.Join(r.dir, filename) | ||
| 164 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 183 | + | data, err := r.readBounded(path) |
| 165 | - | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | 184 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 185 | + | return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err)) | |||
| 166 | } | 186 | } | ||
| 167 | - | r.pendingLegacySync.Store(filename, struct{}{}) | 187 | + | if err == nil && sha256.Sum256(data) != receipt.digest { |
| 168 | - | if err := r.syncPendingLegacy(ctx, filename); err != nil { | 188 | + | return r.clearLegacyDeleteAuthorization(receipt.name) |
| 169 | - | return err | |||
| 170 | } | 189 | } | ||
| 171 | - | return r.clearLegacyDeleteAuthorization(name) | 190 | + | return r.deleteLegacyFile(ctx, filename, receipt.name) |
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error { | |||
| 194 | + | if _, pending := r.pendingLegacySync.Load(filename); pending { | |||
| 195 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |||
| 196 | + | return err | |||
| 197 | + | } | |||
| 198 | + | } else { | |||
| 199 | + | path := filepath.Join(r.dir, filename) | |||
| 200 | + | if err := r.remove(path); err != nil { | |||
| 201 | + | if !errors.Is(err, os.ErrNotExist) { | |||
| 202 | + | return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err)) | |||
| 203 | + | } | |||
| 204 | + | // A missing repository has no deletion to make durable. This preserves | |||
| 205 | + | // idempotence for explicit purges before the snapshot root exists. | |||
| 206 | + | if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) { | |||
| 207 | + | return nil | |||
| 208 | + | } else if statErr != nil { | |||
| 209 | + | return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr)) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | r.pendingLegacySync.Store(filename, struct{}{}) | |||
| 213 | + | if err := r.syncPendingLegacy(ctx, filename); err != nil { | |||
| 214 | + | return err | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | if len(clearReceipt) != 0 { | |||
| 218 | + | return r.clearLegacyDeleteAuthorization(clearReceipt[0]) | |||
| 219 | + | } | |||
| 220 | + | return nil | |||
| 172 | } | 221 | } | ||
| 173 | 222 | ||||
| 174 | func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | 223 | func (r *Repository) clearLegacyDeleteAuthorization(name string) error { | ||
| 175 | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | 224 | path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name))) | ||
| 176 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | 225 | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 177 | - | return fmt.Errorf("clear legacy deletion marker: %w", safeFilesystemError(err)) | 226 | + | return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err)) |
| 178 | } | 227 | } | ||
| 179 | if err := r.syncDirectory(r.dir); err != nil { | 228 | if err := r.syncDirectory(r.dir); err != nil { | ||
| 180 | - | return fmt.Errorf("sync legacy deletion marker directory: %w", safeFilesystemError(err)) | 229 | + | return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err)) |
| 181 | } | 230 | } | ||
| 182 | return nil | 231 | return nil | ||
| 183 | } | 232 | } | ||
| @@ -191,7 +240,11 @@ func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *in | |||||
| 191 | return err | 240 | return err | ||
| 192 | } | 241 | } | ||
| 193 | for _, marker := range markers { | 242 | for _, marker := range markers { | ||
| 194 | - | if err := r.DeleteLegacy(ctx, marker.name); err != nil { | 243 | + | filename := filenameForName(marker.name) |
| 244 | + | lock := r.lockSession("legacy-sync:" + filename) | |||
| 245 | + | err := r.deleteAuthorizedLegacy(ctx, filename, marker) | |||
| 246 | + | r.unlockSession(lock) | |||
| 247 | + | if err != nil { | |||
| 195 | return fmt.Errorf("retry authorized legacy deletion: %w", err) | 248 | return fmt.Errorf("retry authorized legacy deletion: %w", err) | ||
| 196 | } | 249 | } | ||
| 197 | } | 250 | } | ||
| @@ -231,10 +284,16 @@ func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int | |||||
| 231 | } | 284 | } | ||
| 232 | path := filepath.Join(r.dir, entry.Name()) | 285 | path := filepath.Join(r.dir, entry.Name()) | ||
| 233 | data, dataErr := r.readBounded(path) | 286 | data, dataErr := r.readBounded(path) | ||
| 234 | - | if dataErr != nil || len(data) > maxLegacyDeleteNameBytes || sessionKey(string(data)) != key { | 287 | + | if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes { |
| 235 | - | return nil, fmt.Errorf("invalid legacy deletion marker") | 288 | + | return nil, fmt.Errorf("invalid legacy import receipt") |
| 289 | + | } | |||
| 290 | + | name := string(data[sha256.Size:]) | |||
| 291 | + | if sessionKey(name) != key { | |||
| 292 | + | return nil, fmt.Errorf("invalid legacy import receipt") | |||
| 236 | } | 293 | } | ||
| 237 | - | markers = append(markers, legacyDeleteMarker{name: string(data)}) | 294 | + | marker := legacyDeleteMarker{name: name} |
| 295 | + | copy(marker.digest[:], data[:sha256.Size]) | |||
| 296 | + | markers = append(markers, marker) | |||
| 238 | } | 297 | } | ||
| 239 | if errors.Is(readErr, io.EOF) { | 298 | if errors.Is(readErr, io.EOF) { | ||
| 240 | break | 299 | break | ||
M internal/adapters/snapshot/legacy_pending_delete_test.go +30 -3
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) { | |
| @@ -23,10 +25,12 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) | |||
| 23 | 25 | } | |
| 24 | 26 | return os.Remove(path) | |
| 25 | 27 | } | |
| 26 | - | require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), injected) | |
| 28 | + | // The injected unlink fault is after receipt persistence, simulating a | |
| 29 | + | // crash boundary where publication was verified but source cleanup was not. | |
| 30 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected) | |
| 27 | 31 | ||
| 28 | - | // A new repository has no in-memory retry state. The durable authorization | |
| 29 | - | // must fence import and complete deletion without another publication. | |
| 32 | + | // A new repository has no in-memory retry state. The durable receipt must | |
| 33 | + | // fence import and complete deletion without another publication. | |
| 30 | 34 | repo = NewRepository(repo.dir) | |
| 31 | 35 | legacy, err := repo.LoadLegacy(context.Background()) | |
| 32 | 36 | require.NoError(t, err) | |
| @@ -34,3 +38,26 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) | |||
| 34 | 38 | _, err = os.Stat(path) | |
| 35 | 39 | require.ErrorIs(t, err, os.ErrNotExist) | |
| 36 | 40 | } | |
| 41 | + | ||
| 42 | + | func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) { | |
| 43 | + | repo := NewRepository(privateDir(t)) | |
| 44 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |
| 45 | + | path := filepath.Join(repo.dir, filenameForName("named")) | |
| 46 | + | require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600)) | |
| 47 | + | ||
| 48 | + | injected := errors.New("unlink failed") | |
| 49 | + | repo.hooks.remove = func(path string) error { | |
| 50 | + | if filepath.Base(path) == filenameForName("named") { | |
| 51 | + | return injected | |
| 52 | + | } | |
| 53 | + | return os.Remove(path) | |
| 54 | + | } | |
| 55 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected) | |
| 56 | + | require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600)) | |
| 57 | + | ||
| 58 | + | repo = NewRepository(repo.dir) | |
| 59 | + | legacy, err := repo.LoadLegacy(context.Background()) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy) | |
| 62 | + | require.FileExists(t, path) | |
| 63 | + | } | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "testing" | 8 | "testing" | ||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) { | 15 | func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) { | ||
| @@ -23,10 +25,12 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) | |||||
| 23 | } | 25 | } | ||
| 24 | return os.Remove(path) | 26 | return os.Remove(path) | ||
| 25 | } | 27 | } | ||
| 26 | - | require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), injected) | 28 | + | // The injected unlink fault is after receipt persistence, simulating a |
| 29 | + | // crash boundary where publication was verified but source cleanup was not. | |||
| 30 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected) | |||
| 27 | 31 | ||||
| 28 | - | // A new repository has no in-memory retry state. The durable authorization | 32 | + | // A new repository has no in-memory retry state. The durable receipt must |
| 29 | - | // must fence import and complete deletion without another publication. | 33 | + | // fence import and complete deletion without another publication. |
| 30 | repo = NewRepository(repo.dir) | 34 | repo = NewRepository(repo.dir) | ||
| 31 | legacy, err := repo.LoadLegacy(context.Background()) | 35 | legacy, err := repo.LoadLegacy(context.Background()) | ||
| 32 | require.NoError(t, err) | 36 | require.NoError(t, err) | ||
| @@ -34,3 +38,26 @@ func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) | |||||
| 34 | _, err = os.Stat(path) | 38 | _, err = os.Stat(path) | ||
| 35 | require.ErrorIs(t, err, os.ErrNotExist) | 39 | require.ErrorIs(t, err, os.ErrNotExist) | ||
| 36 | } | 40 | } | ||
| 41 | + | ||||
| 42 | + | func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) { | |||
| 43 | + | repo := NewRepository(privateDir(t)) | |||
| 44 | + | require.NoError(t, os.Mkdir(repo.dir, 0o700)) | |||
| 45 | + | path := filepath.Join(repo.dir, filenameForName("named")) | |||
| 46 | + | require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600)) | |||
| 47 | + | ||||
| 48 | + | injected := errors.New("unlink failed") | |||
| 49 | + | repo.hooks.remove = func(path string) error { | |||
| 50 | + | if filepath.Base(path) == filenameForName("named") { | |||
| 51 | + | return injected | |||
| 52 | + | } | |||
| 53 | + | return os.Remove(path) | |||
| 54 | + | } | |||
| 55 | + | require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected) | |||
| 56 | + | require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600)) | |||
| 57 | + | ||||
| 58 | + | repo = NewRepository(repo.dir) | |||
| 59 | + | legacy, err := repo.LoadLegacy(context.Background()) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy) | |||
| 62 | + | require.FileExists(t, path) | |||
| 63 | + | } | |||
M internal/adapters/snapshot/legacy_test.go +1 -1
| @@ -135,7 +135,7 @@ func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) { | |||
| 135 | 135 | if err := repo.DeleteLegacy(context.Background(), "named"); err != nil { | |
| 136 | 136 | t.Fatalf("retry DeleteLegacy: %v", err) | |
| 137 | 137 | } | |
| 138 | - | if syncCalls != 4 { | |
| 138 | + | if syncCalls != 2 { | |
| 139 | 139 | t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls) | |
| 140 | 140 | } | |
| 141 | 141 | } | |
| @@ -135,7 +135,7 @@ func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) { | |||||
| 135 | if err := repo.DeleteLegacy(context.Background(), "named"); err != nil { | 135 | if err := repo.DeleteLegacy(context.Background(), "named"); err != nil { | ||
| 136 | t.Fatalf("retry DeleteLegacy: %v", err) | 136 | t.Fatalf("retry DeleteLegacy: %v", err) | ||
| 137 | } | 137 | } | ||
| 138 | - | if syncCalls != 4 { | 138 | + | if syncCalls != 2 { |
| 139 | t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls) | 139 | t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls) | ||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
M internal/adapters/snapshot/offline_kill_tombstone_test.go +1 -1
| @@ -49,5 +49,5 @@ func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) { | |||
| 49 | 49 | return nil | |
| 50 | 50 | } | |
| 51 | 51 | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | |
| 52 | - | require.Equal(t, 4, calls, "authorization and its cleanup are durably synced around the retry") | |
| 52 | + | require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync") | |
| 53 | 53 | } | |
| @@ -49,5 +49,5 @@ func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) { | |||||
| 49 | return nil | 49 | return nil | ||
| 50 | } | 50 | } | ||
| 51 | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | 51 | require.NoError(t, repo.DeleteLegacy(context.Background(), "named")) | ||
| 52 | - | require.Equal(t, 4, calls, "authorization and its cleanup are durably synced around the retry") | 52 | + | require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync") |
| 53 | } | 53 | } | ||
M internal/ports/mocks/store_mock.go +57 -0
| @@ -854,6 +854,63 @@ func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(cont | |||
| 854 | 854 | return _c | |
| 855 | 855 | } | |
| 856 | 856 | ||
| 857 | + | // DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource | |
| 858 | + | func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error { | |
| 859 | + | ret := _mock.Called(context1, legacySnapshot) | |
| 860 | + | ||
| 861 | + | if len(ret) == 0 { | |
| 862 | + | panic("no return value specified for DeleteVerifiedLegacy") | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | var r0 error | |
| 866 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok { | |
| 867 | + | r0 = returnFunc(context1, legacySnapshot) | |
| 868 | + | } else { | |
| 869 | + | r0 = ret.Error(0) | |
| 870 | + | } | |
| 871 | + | return r0 | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy' | |
| 875 | + | type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct { | |
| 876 | + | *mock.Call | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | // DeleteVerifiedLegacy is a helper method to define mock.On call | |
| 880 | + | // - context1 context.Context | |
| 881 | + | // - legacySnapshot ports.LegacySnapshot | |
| 882 | + | func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 883 | + | return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)} | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 887 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 888 | + | var arg0 context.Context | |
| 889 | + | if args[0] != nil { | |
| 890 | + | arg0 = args[0].(context.Context) | |
| 891 | + | } | |
| 892 | + | var arg1 ports.LegacySnapshot | |
| 893 | + | if args[1] != nil { | |
| 894 | + | arg1 = args[1].(ports.LegacySnapshot) | |
| 895 | + | } | |
| 896 | + | run( | |
| 897 | + | arg0, | |
| 898 | + | arg1, | |
| 899 | + | ) | |
| 900 | + | }) | |
| 901 | + | return _c | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 905 | + | _c.Call.Return(err) | |
| 906 | + | return _c | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |
| 910 | + | _c.Call.Return(run) | |
| 911 | + | return _c | |
| 912 | + | } | |
| 913 | + | ||
| 857 | 914 | // LoadLegacy provides a mock function for the type MockLegacySnapshotSource | |
| 858 | 915 | func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) { | |
| 859 | 916 | ret := _mock.Called(context1) | |
| @@ -854,6 +854,63 @@ func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(cont | |||||
| 854 | return _c | 854 | return _c | ||
| 855 | } | 855 | } | ||
| 856 | 856 | ||||
| 857 | + | // DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource | |||
| 858 | + | func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error { | |||
| 859 | + | ret := _mock.Called(context1, legacySnapshot) | |||
| 860 | + | ||||
| 861 | + | if len(ret) == 0 { | |||
| 862 | + | panic("no return value specified for DeleteVerifiedLegacy") | |||
| 863 | + | } | |||
| 864 | + | ||||
| 865 | + | var r0 error | |||
| 866 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok { | |||
| 867 | + | r0 = returnFunc(context1, legacySnapshot) | |||
| 868 | + | } else { | |||
| 869 | + | r0 = ret.Error(0) | |||
| 870 | + | } | |||
| 871 | + | return r0 | |||
| 872 | + | } | |||
| 873 | + | ||||
| 874 | + | // MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy' | |||
| 875 | + | type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct { | |||
| 876 | + | *mock.Call | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | // DeleteVerifiedLegacy is a helper method to define mock.On call | |||
| 880 | + | // - context1 context.Context | |||
| 881 | + | // - legacySnapshot ports.LegacySnapshot | |||
| 882 | + | func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |||
| 883 | + | return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)} | |||
| 884 | + | } | |||
| 885 | + | ||||
| 886 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |||
| 887 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 888 | + | var arg0 context.Context | |||
| 889 | + | if args[0] != nil { | |||
| 890 | + | arg0 = args[0].(context.Context) | |||
| 891 | + | } | |||
| 892 | + | var arg1 ports.LegacySnapshot | |||
| 893 | + | if args[1] != nil { | |||
| 894 | + | arg1 = args[1].(ports.LegacySnapshot) | |||
| 895 | + | } | |||
| 896 | + | run( | |||
| 897 | + | arg0, | |||
| 898 | + | arg1, | |||
| 899 | + | ) | |||
| 900 | + | }) | |||
| 901 | + | return _c | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |||
| 905 | + | _c.Call.Return(err) | |||
| 906 | + | return _c | |||
| 907 | + | } | |||
| 908 | + | ||||
| 909 | + | func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call { | |||
| 910 | + | _c.Call.Return(run) | |||
| 911 | + | return _c | |||
| 912 | + | } | |||
| 913 | + | ||||
| 857 | // LoadLegacy provides a mock function for the type MockLegacySnapshotSource | 914 | // LoadLegacy provides a mock function for the type MockLegacySnapshotSource | ||
| 858 | func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) { | 915 | func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) { | ||
| 859 | ret := _mock.Called(context1) | 916 | ret := _mock.Called(context1) | ||
M internal/ports/store.go +5 -0
| @@ -75,5 +75,10 @@ type LegacySnapshot struct { | |||
| 75 | 75 | // contract. | |
| 76 | 76 | type LegacySnapshotSource interface { | |
| 77 | 77 | LoadLegacy(context.Context) ([]LegacySnapshot, error) | |
| 78 | + | // DeleteVerifiedLegacy removes precisely the legacy blob that was verified | |
| 79 | + | // after import. Implementations must persist its identity before unlinking. | |
| 80 | + | DeleteVerifiedLegacy(context.Context, LegacySnapshot) error | |
| 81 | + | // DeleteLegacy is reserved for explicit session purges, which intentionally | |
| 82 | + | // delete by name rather than as part of an import receipt. | |
| 78 | 83 | DeleteLegacy(context.Context, string) error | |
| 79 | 84 | } | |
| @@ -75,5 +75,10 @@ type LegacySnapshot struct { | |||||
| 75 | // contract. | 75 | // contract. | ||
| 76 | type LegacySnapshotSource interface { | 76 | type LegacySnapshotSource interface { | ||
| 77 | LoadLegacy(context.Context) ([]LegacySnapshot, error) | 77 | LoadLegacy(context.Context) ([]LegacySnapshot, error) | ||
| 78 | + | // DeleteVerifiedLegacy removes precisely the legacy blob that was verified | |||
| 79 | + | // after import. Implementations must persist its identity before unlinking. | |||
| 80 | + | DeleteVerifiedLegacy(context.Context, LegacySnapshot) error | |||
| 81 | + | // DeleteLegacy is reserved for explicit session purges, which intentionally | |||
| 82 | + | // delete by name rather than as part of an import receipt. | |||
| 78 | DeleteLegacy(context.Context, string) error | 83 | DeleteLegacy(context.Context, string) error | ||
| 79 | } | 84 | } | ||
M internal/usecase/daemon/session_purge_retry_test.go +3 -0
| @@ -34,6 +34,9 @@ func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error | |||
| 34 | 34 | func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) { | |
| 35 | 35 | return nil, nil | |
| 36 | 36 | } | |
| 37 | + | func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 38 | + | return r.DeleteLegacy(ctx, blob.Name) | |
| 39 | + | } | |
| 37 | 40 | func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error { | |
| 38 | 41 | r.calls = append(r.calls, "legacy") | |
| 39 | 42 | return r.legacyErr | |
| @@ -34,6 +34,9 @@ func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error | |||||
| 34 | func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) { | 34 | func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) { | ||
| 35 | return nil, nil | 35 | return nil, nil | ||
| 36 | } | 36 | } | ||
| 37 | + | func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |||
| 38 | + | return r.DeleteLegacy(ctx, blob.Name) | |||
| 39 | + | } | |||
| 37 | func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error { | 40 | func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error { | ||
| 38 | r.calls = append(r.calls, "legacy") | 41 | r.calls = append(r.calls, "legacy") | ||
| 39 | return r.legacyErr | 42 | return r.legacyErr | ||
M internal/usecase/daemon/snapshot_import_test.go +33 -15
| @@ -2,7 +2,10 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "errors" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 6 | 9 | "testing" | |
| 7 | 10 | ||
| 8 | 11 | "github.com/stretchr/testify/require" | |
| @@ -30,6 +33,10 @@ func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]port | |||
| 30 | 33 | return out, nil | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 36 | + | func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |
| 37 | + | return s.DeleteLegacy(ctx, blob.Name) | |
| 38 | + | } | |
| 39 | + | ||
| 33 | 40 | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | |
| 34 | 41 | if err := ctx.Err(); err != nil { | |
| 35 | 42 | return err | |
| @@ -47,15 +54,30 @@ func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name | |||
| 47 | 54 | return nil | |
| 48 | 55 | } | |
| 49 | 56 | ||
| 57 | + | func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session { | |
| 58 | + | t.Helper() | |
| 59 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | snapshot, err := snapcodec.Unmarshal(encoded) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | return snapshot | |
| 66 | + | } | |
| 67 | + | ||
| 50 | 68 | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | |
| 51 | 69 | t.Helper() | |
| 52 | - | data, err := snapcodec.Marshal(snapshot) | |
| 70 | + | fixture := legacyAcceptanceSnapshot(t) | |
| 71 | + | require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session") | |
| 72 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |
| 53 | 75 | require.NoError(t, err) | |
| 54 | - | return ports.LegacySnapshot{Name: snapshot.Name, Data: data} | |
| 76 | + | return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded} | |
| 55 | 77 | } | |
| 56 | 78 | ||
| 57 | 79 | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | |
| 58 | - | snapshot := restoreAcceptanceSession(t, "legacy") | |
| 80 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 59 | 81 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 60 | 82 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 61 | 83 | d := newTestDaemon(t, nil, stubClock{}) | |
| @@ -75,7 +97,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing. | |||
| 75 | 97 | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | |
| 76 | 98 | require.NoError(t, err) | |
| 77 | 99 | require.Equal(t, uint64(1), manifest.Generation) | |
| 78 | - | require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2) | |
| 100 | + | require.Empty(t, manifest.Tabs) | |
| 79 | 101 | for _, object := range publication.Objects { | |
| 80 | 102 | kind, _, err := snapcodec.UnmarshalObject(object.Data) | |
| 81 | 103 | require.NoError(t, err) | |
| @@ -87,7 +109,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing. | |||
| 87 | 109 | } | |
| 88 | 110 | ||
| 89 | 111 | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |
| 90 | - | snapshot := restoreAcceptanceSession(t, "already-incremental") | |
| 112 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 91 | 113 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | |
| 92 | 114 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | |
| 93 | 115 | d := newTestDaemon(t, nil, stubClock{}) | |
| @@ -101,7 +123,7 @@ func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |||
| 101 | 123 | } | |
| 102 | 124 | ||
| 103 | 125 | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |
| 104 | - | snapshot := restoreAcceptanceSession(t, "retain") | |
| 126 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 105 | 127 | for _, test := range []struct { | |
| 106 | 128 | name string | |
| 107 | 129 | publish error | |
| @@ -114,12 +136,8 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |||
| 114 | 136 | g.Generation++ | |
| 115 | 137 | return g | |
| 116 | 138 | }}, | |
| 117 | - | {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 118 | - | for digest, data := range g.Objects { | |
| 119 | - | data[len(data)-1] ^= 1 | |
| 120 | - | g.Objects[digest] = data | |
| 121 | - | break | |
| 122 | - | } | |
| 139 | + | {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | |
| 140 | + | g.Manifest[len(g.Manifest)-1] ^= 1 | |
| 123 | 141 | return g | |
| 124 | 142 | }}, | |
| 125 | 143 | } { | |
| @@ -136,7 +154,7 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |||
| 136 | 154 | } | |
| 137 | 155 | ||
| 138 | 156 | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |
| 139 | - | snapshot := restoreAcceptanceSession(t, "retry-delete") | |
| 157 | + | snapshot := legacyAcceptanceSnapshot(t) | |
| 140 | 158 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| 141 | 159 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | |
| 142 | 160 | d := newTestDaemon(t, nil, stubClock{}) | |
| @@ -152,8 +170,8 @@ func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |||
| 152 | 170 | } | |
| 153 | 171 | ||
| 154 | 172 | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | |
| 155 | - | bad := restoreAcceptanceSession(t, "bad") | |
| 156 | - | good := restoreAcceptanceSession(t, "good") | |
| 173 | + | bad := legacyAcceptanceSnapshot(t) | |
| 174 | + | good := legacyAcceptanceSnapshot(t) | |
| 157 | 175 | badBlob := legacyAcceptanceBlob(t, bad) | |
| 158 | 176 | badBlob.Data = []byte("not-a-v3-snapshot") | |
| 159 | 177 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | |
| @@ -2,7 +2,10 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/base64" | |||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 6 | "testing" | 9 | "testing" | ||
| 7 | 10 | ||||
| 8 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| @@ -30,6 +33,10 @@ func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]port | |||||
| 30 | return out, nil | 33 | return out, nil | ||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 36 | + | func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error { | |||
| 37 | + | return s.DeleteLegacy(ctx, blob.Name) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 33 | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | 40 | func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error { | ||
| 34 | if err := ctx.Err(); err != nil { | 41 | if err := ctx.Err(); err != nil { | ||
| 35 | return err | 42 | return err | ||
| @@ -47,15 +54,30 @@ func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name | |||||
| 47 | return nil | 54 | return nil | ||
| 48 | } | 55 | } | ||
| 49 | 56 | ||||
| 57 | + | func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session { | |||
| 58 | + | t.Helper() | |||
| 59 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | snapshot, err := snapcodec.Unmarshal(encoded) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | return snapshot | |||
| 66 | + | } | |||
| 67 | + | ||||
| 50 | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | 68 | func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot { | ||
| 51 | t.Helper() | 69 | t.Helper() | ||
| 52 | - | data, err := snapcodec.Marshal(snapshot) | 70 | + | fixture := legacyAcceptanceSnapshot(t) |
| 71 | + | require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session") | |||
| 72 | + | data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64")) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | encoded, err := base64.StdEncoding.DecodeString(string(data)) | |||
| 53 | require.NoError(t, err) | 75 | require.NoError(t, err) | ||
| 54 | - | return ports.LegacySnapshot{Name: snapshot.Name, Data: data} | 76 | + | return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded} |
| 55 | } | 77 | } | ||
| 56 | 78 | ||||
| 57 | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | 79 | func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) { | ||
| 58 | - | snapshot := restoreAcceptanceSession(t, "legacy") | 80 | + | snapshot := legacyAcceptanceSnapshot(t) |
| 59 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | 81 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | ||
| 60 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | 82 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | ||
| 61 | d := newTestDaemon(t, nil, stubClock{}) | 83 | d := newTestDaemon(t, nil, stubClock{}) | ||
| @@ -75,7 +97,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing. | |||||
| 75 | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | 97 | manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) | ||
| 76 | require.NoError(t, err) | 98 | require.NoError(t, err) | ||
| 77 | require.Equal(t, uint64(1), manifest.Generation) | 99 | require.Equal(t, uint64(1), manifest.Generation) | ||
| 78 | - | require.Len(t, manifest.Tabs[0].Panes[0].Sealed, 2) | 100 | + | require.Empty(t, manifest.Tabs) |
| 79 | for _, object := range publication.Objects { | 101 | for _, object := range publication.Objects { | ||
| 80 | kind, _, err := snapcodec.UnmarshalObject(object.Data) | 102 | kind, _, err := snapcodec.UnmarshalObject(object.Data) | ||
| 81 | require.NoError(t, err) | 103 | require.NoError(t, err) | ||
| @@ -87,7 +109,7 @@ func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing. | |||||
| 87 | } | 109 | } | ||
| 88 | 110 | ||||
| 89 | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | 111 | func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | ||
| 90 | - | snapshot := restoreAcceptanceSession(t, "already-incremental") | 112 | + | snapshot := legacyAcceptanceSnapshot(t) |
| 91 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | 113 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)} | ||
| 92 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | 114 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}} | ||
| 93 | d := newTestDaemon(t, nil, stubClock{}) | 115 | d := newTestDaemon(t, nil, stubClock{}) | ||
| @@ -101,7 +123,7 @@ func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) { | |||||
| 101 | } | 123 | } | ||
| 102 | 124 | ||||
| 103 | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | 125 | func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | ||
| 104 | - | snapshot := restoreAcceptanceSession(t, "retain") | 126 | + | snapshot := legacyAcceptanceSnapshot(t) |
| 105 | for _, test := range []struct { | 127 | for _, test := range []struct { | ||
| 106 | name string | 128 | name string | ||
| 107 | publish error | 129 | publish error | ||
| @@ -114,12 +136,8 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |||||
| 114 | g.Generation++ | 136 | g.Generation++ | ||
| 115 | return g | 137 | return g | ||
| 116 | }}, | 138 | }}, | ||
| 117 | - | {"object bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { | 139 | + | {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { |
| 118 | - | for digest, data := range g.Objects { | 140 | + | g.Manifest[len(g.Manifest)-1] ^= 1 |
| 119 | - | data[len(data)-1] ^= 1 | |||
| 120 | - | g.Objects[digest] = data | |||
| 121 | - | break | |||
| 122 | - | } | |||
| 123 | return g | 141 | return g | ||
| 124 | }}, | 142 | }}, | ||
| 125 | } { | 143 | } { | ||
| @@ -136,7 +154,7 @@ func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) { | |||||
| 136 | } | 154 | } | ||
| 137 | 155 | ||||
| 138 | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | 156 | func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | ||
| 139 | - | snapshot := restoreAcceptanceSession(t, "retry-delete") | 157 | + | snapshot := legacyAcceptanceSnapshot(t) |
| 140 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | 158 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | ||
| 141 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | 159 | legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")} | ||
| 142 | d := newTestDaemon(t, nil, stubClock{}) | 160 | d := newTestDaemon(t, nil, stubClock{}) | ||
| @@ -152,8 +170,8 @@ func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) { | |||||
| 152 | } | 170 | } | ||
| 153 | 171 | ||||
| 154 | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | 172 | func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) { | ||
| 155 | - | bad := restoreAcceptanceSession(t, "bad") | 173 | + | bad := legacyAcceptanceSnapshot(t) |
| 156 | - | good := restoreAcceptanceSession(t, "good") | 174 | + | good := legacyAcceptanceSnapshot(t) |
| 157 | badBlob := legacyAcceptanceBlob(t, bad) | 175 | badBlob := legacyAcceptanceBlob(t, bad) | ||
| 158 | badBlob.Data = []byte("not-a-v3-snapshot") | 176 | badBlob.Data = []byte("not-a-v3-snapshot") | ||
| 159 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | 177 | repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)} | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +2 -2
| @@ -150,7 +150,7 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||
| 150 | 150 | continue | |
| 151 | 151 | } | |
| 152 | 152 | d.rememberLegacyDelete(blob) | |
| 153 | - | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | |
| 153 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { | |
| 154 | 154 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 155 | 155 | continue | |
| 156 | 156 | } | |
| @@ -207,7 +207,7 @@ func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapsho | |||
| 207 | 207 | if !ok || !bytes.Equal(pending, blob.Data) { | |
| 208 | 208 | return | |
| 209 | 209 | } | |
| 210 | - | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | |
| 210 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { | |
| 211 | 211 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | |
| 212 | 212 | return | |
| 213 | 213 | } | |
| @@ -150,7 +150,7 @@ func (d *Daemon) importLegacySnapshots(ctx context.Context) { | |||||
| 150 | continue | 150 | continue | ||
| 151 | } | 151 | } | ||
| 152 | d.rememberLegacyDelete(blob) | 152 | d.rememberLegacyDelete(blob) | ||
| 153 | - | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | 153 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { |
| 154 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | 154 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | ||
| 155 | continue | 155 | continue | ||
| 156 | } | 156 | } | ||
| @@ -207,7 +207,7 @@ func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapsho | |||||
| 207 | if !ok || !bytes.Equal(pending, blob.Data) { | 207 | if !ok || !bytes.Equal(pending, blob.Data) { | ||
| 208 | return | 208 | return | ||
| 209 | } | 209 | } | ||
| 210 | - | if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil { | 210 | + | if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil { |
| 211 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | 211 | d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err) | ||
| 212 | return | 212 | return | ||
| 213 | } | 213 | } | ||
A internal/usecase/daemon/testdata/legacy-v3-empty.b64 +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA= | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | VkVWUwADAAAAAAAc4u6s7AAObGVnYWN5LWZpeHR1cmUAAAAAAAAAAAAAAAA= | |||
M internal/usecase/snapshot/codec.go +55 -157
| @@ -34,24 +34,6 @@ const ( | |||
| 34 | 34 | maxSnapshotDecodedAllocation = 256 << 20 | |
| 35 | 35 | ) | |
| 36 | 36 | ||
| 37 | - | // Marshal encodes the strict v3, flags-zero durable envelope. | |
| 38 | - | func Marshal(s Session) ([]byte, error) { | |
| 39 | - | var w payloadWriter | |
| 40 | - | if err := writeSession(&w, s); err != nil { | |
| 41 | - | return nil, err | |
| 42 | - | } | |
| 43 | - | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |
| 44 | - | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 45 | - | } | |
| 46 | - | out := make([]byte, 16+len(w.b)) | |
| 47 | - | copy(out[:4], magic) | |
| 48 | - | binary.BigEndian.PutUint16(out[4:6], version) | |
| 49 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |
| 50 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |
| 51 | - | copy(out[16:], w.b) | |
| 52 | - | return out, nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | 37 | // Unmarshal validates the complete manifest and all VT blobs without | |
| 56 | 38 | // allocations first, then decodes the already preflighted data. | |
| 57 | 39 | func Unmarshal(b []byte) (Session, error) { | |
| @@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error { | |||
| 146 | 128 | return nil | |
| 147 | 129 | } | |
| 148 | 130 | ||
| 131 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 132 | + | if n == nil { | |
| 133 | + | w.putUint8(manifestNodeNil) | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | switch n.Kind { | |
| 137 | + | case layout.Leaf: | |
| 138 | + | w.putUint8(manifestNodeLeaf) | |
| 139 | + | return w.putString(string(n.Leaf)) | |
| 140 | + | case layout.Split: | |
| 141 | + | w.putUint8(manifestNodeSplit) | |
| 142 | + | w.putUint8(uint8(n.Dir)) | |
| 143 | + | return writeChildren(w, n.Children) | |
| 144 | + | case layout.Stack: | |
| 145 | + | w.putUint8(manifestNodeStack) | |
| 146 | + | if err := w.putString(string(n.Expanded)); err != nil { | |
| 147 | + | return err | |
| 148 | + | } | |
| 149 | + | return writeChildren(w, n.Children) | |
| 150 | + | default: | |
| 151 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 156 | + | if len(children) > math.MaxUint16 { | |
| 157 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 158 | + | } | |
| 159 | + | w.putUint16(uint16(len(children))) | |
| 160 | + | for _, c := range children { | |
| 161 | + | if err := writeNode(w, c); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func writeProcess(w *payloadWriter, p *Process) error { | |
| 169 | + | if p == nil { | |
| 170 | + | w.putUint8(processAbsent) | |
| 171 | + | return nil | |
| 172 | + | } | |
| 173 | + | if len(p.Argv) == 0 { | |
| 174 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 175 | + | } | |
| 176 | + | w.putUint8(processPresent) | |
| 177 | + | if err := w.putStrings(p.Argv); err != nil { | |
| 178 | + | return err | |
| 179 | + | } | |
| 180 | + | if err := w.putString(p.Strategy); err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | return w.putString(p.Opts.AgentSessionID) | |
| 184 | + | } | |
| 185 | + | ||
| 149 | 186 | type payloadReader struct{ b []byte } | |
| 150 | 187 | ||
| 151 | 188 | func (r *payloadReader) getUint8() (uint8, error) { | |
| @@ -226,27 +263,6 @@ func (r *payloadReader) done() error { | |||
| 226 | 263 | return nil | |
| 227 | 264 | } | |
| 228 | 265 | ||
| 229 | - | func writeSession(w *payloadWriter, s Session) error { | |
| 230 | - | if len(s.Tabs) > math.MaxUint16 { | |
| 231 | - | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 232 | - | } | |
| 233 | - | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |
| 234 | - | return err | |
| 235 | - | } | |
| 236 | - | if err := w.putString(s.Name); err != nil { | |
| 237 | - | return err | |
| 238 | - | } | |
| 239 | - | w.putUint64(s.CreatedAt) | |
| 240 | - | w.putUint16(s.Active) | |
| 241 | - | w.putUint16(uint16(len(s.Tabs))) | |
| 242 | - | for _, t := range s.Tabs { | |
| 243 | - | if err := writeTab(w, t); err != nil { | |
| 244 | - | return err | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | return nil | |
| 248 | - | } | |
| 249 | - | ||
| 250 | 266 | func readSession(r *payloadReader) (Session, error) { | |
| 251 | 267 | name, err := r.getString() | |
| 252 | 268 | if err != nil { | |
| @@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) { | |||
| 278 | 294 | return s, nil | |
| 279 | 295 | } | |
| 280 | 296 | ||
| 281 | - | func writeTab(w *payloadWriter, t Tab) error { | |
| 282 | - | if err := w.putString(t.StableID); err != nil { | |
| 283 | - | return err | |
| 284 | - | } | |
| 285 | - | w.putUint16(t.Cols) | |
| 286 | - | w.putUint16(t.Rows) | |
| 287 | - | w.putUint64(t.NextPaneID) | |
| 288 | - | if err := w.putString(string(t.Focus)); err != nil { | |
| 289 | - | return err | |
| 290 | - | } | |
| 291 | - | var root *layout.Node | |
| 292 | - | if t.Tree != nil { | |
| 293 | - | root = t.Tree.Root | |
| 294 | - | } | |
| 295 | - | if err := writeNode(w, root); err != nil { | |
| 296 | - | return err | |
| 297 | - | } | |
| 298 | - | if len(t.Panes) > math.MaxUint16 { | |
| 299 | - | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 300 | - | } | |
| 301 | - | w.putUint16(uint16(len(t.Panes))) | |
| 302 | - | for _, p := range t.Panes { | |
| 303 | - | if err := writePane(w, p); err != nil { | |
| 304 | - | return err | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | return nil | |
| 308 | - | } | |
| 309 | - | ||
| 310 | 297 | func readTab(r *payloadReader) (Tab, error) { | |
| 311 | 298 | stableID, err := r.getString() | |
| 312 | 299 | if err != nil { | |
| @@ -347,43 +334,6 @@ func readTab(r *payloadReader) (Tab, error) { | |||
| 347 | 334 | return t, nil | |
| 348 | 335 | } | |
| 349 | 336 | ||
| 350 | - | func writeNode(w *payloadWriter, n *layout.Node) error { | |
| 351 | - | if n == nil { | |
| 352 | - | w.putUint8(manifestNodeNil) | |
| 353 | - | return nil | |
| 354 | - | } | |
| 355 | - | switch n.Kind { | |
| 356 | - | case layout.Leaf: | |
| 357 | - | w.putUint8(manifestNodeLeaf) | |
| 358 | - | return w.putString(string(n.Leaf)) | |
| 359 | - | case layout.Split: | |
| 360 | - | w.putUint8(manifestNodeSplit) | |
| 361 | - | w.putUint8(uint8(n.Dir)) | |
| 362 | - | return writeChildren(w, n.Children) | |
| 363 | - | case layout.Stack: | |
| 364 | - | w.putUint8(manifestNodeStack) | |
| 365 | - | if err := w.putString(string(n.Expanded)); err != nil { | |
| 366 | - | return err | |
| 367 | - | } | |
| 368 | - | return writeChildren(w, n.Children) | |
| 369 | - | default: | |
| 370 | - | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |
| 371 | - | } | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |
| 375 | - | if len(children) > math.MaxUint16 { | |
| 376 | - | return fmt.Errorf("%w: too many children", ErrInvalidData) | |
| 377 | - | } | |
| 378 | - | w.putUint16(uint16(len(children))) | |
| 379 | - | for _, c := range children { | |
| 380 | - | if err := writeNode(w, c); err != nil { | |
| 381 | - | return err | |
| 382 | - | } | |
| 383 | - | } | |
| 384 | - | return nil | |
| 385 | - | } | |
| 386 | - | ||
| 387 | 337 | func readNode(r *payloadReader) (*layout.Node, error) { | |
| 388 | 338 | kind, err := r.getUint8() | |
| 389 | 339 | if err != nil { | |
| @@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) { | |||
| 439 | 389 | return out, nil | |
| 440 | 390 | } | |
| 441 | 391 | ||
| 442 | - | func writePane(w *payloadWriter, p Pane) error { | |
| 443 | - | if err := w.putString(string(p.ID)); err != nil { | |
| 444 | - | return err | |
| 445 | - | } | |
| 446 | - | if err := w.putString(p.StableID); err != nil { | |
| 447 | - | return err | |
| 448 | - | } | |
| 449 | - | if err := w.putString(p.Cwd); err != nil { | |
| 450 | - | return err | |
| 451 | - | } | |
| 452 | - | if len(p.SealedChunks) > maxSnapshotObjects { | |
| 453 | - | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 454 | - | } | |
| 455 | - | w.putUint32(uint32(len(p.SealedChunks))) | |
| 456 | - | for _, blob := range p.SealedChunks { | |
| 457 | - | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |
| 458 | - | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |
| 459 | - | } | |
| 460 | - | w.putUint32(uint32(len(blob))) | |
| 461 | - | w.b = append(w.b, blob...) | |
| 462 | - | } | |
| 463 | - | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |
| 464 | - | return fmt.Errorf("%w: tail length", ErrInvalidData) | |
| 465 | - | } | |
| 466 | - | w.putUint32(uint32(len(p.Tail))) | |
| 467 | - | w.b = append(w.b, p.Tail...) | |
| 468 | - | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 469 | - | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 470 | - | } | |
| 471 | - | w.putUint32(uint32(len(p.Visible))) | |
| 472 | - | w.b = append(w.b, p.Visible...) | |
| 473 | - | return writeProcess(w, p.Process) | |
| 474 | - | } | |
| 475 | - | ||
| 476 | 392 | func readPane(r *payloadReader) (Pane, error) { | |
| 477 | 393 | id, err := r.getString() | |
| 478 | 394 | if err != nil { | |
| @@ -514,24 +430,6 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 514 | 430 | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | |
| 515 | 431 | } | |
| 516 | 432 | ||
| 517 | - | func writeProcess(w *payloadWriter, p *Process) error { | |
| 518 | - | if p == nil { | |
| 519 | - | w.putUint8(processAbsent) | |
| 520 | - | return nil | |
| 521 | - | } | |
| 522 | - | if len(p.Argv) == 0 { | |
| 523 | - | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 524 | - | } | |
| 525 | - | w.putUint8(processPresent) | |
| 526 | - | if err := w.putStrings(p.Argv); err != nil { | |
| 527 | - | return err | |
| 528 | - | } | |
| 529 | - | if err := w.putString(p.Strategy); err != nil { | |
| 530 | - | return err | |
| 531 | - | } | |
| 532 | - | return w.putString(p.Opts.AgentSessionID) | |
| 533 | - | } | |
| 534 | - | ||
| 535 | 433 | func readProcess(r *payloadReader) (*Process, error) { | |
| 536 | 434 | present, err := r.getUint8() | |
| 537 | 435 | if err != nil { | |
| @@ -34,24 +34,6 @@ const ( | |||||
| 34 | maxSnapshotDecodedAllocation = 256 << 20 | 34 | maxSnapshotDecodedAllocation = 256 << 20 | ||
| 35 | ) | 35 | ) | ||
| 36 | 36 | ||||
| 37 | - | // Marshal encodes the strict v3, flags-zero durable envelope. | |||
| 38 | - | func Marshal(s Session) ([]byte, error) { | |||
| 39 | - | var w payloadWriter | |||
| 40 | - | if err := writeSession(&w, s); err != nil { | |||
| 41 | - | return nil, err | |||
| 42 | - | } | |||
| 43 | - | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |||
| 44 | - | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |||
| 45 | - | } | |||
| 46 | - | out := make([]byte, 16+len(w.b)) | |||
| 47 | - | copy(out[:4], magic) | |||
| 48 | - | binary.BigEndian.PutUint16(out[4:6], version) | |||
| 49 | - | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |||
| 50 | - | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |||
| 51 | - | copy(out[16:], w.b) | |||
| 52 | - | return out, nil | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | // Unmarshal validates the complete manifest and all VT blobs without | 37 | // Unmarshal validates the complete manifest and all VT blobs without | ||
| 56 | // allocations first, then decodes the already preflighted data. | 38 | // allocations first, then decodes the already preflighted data. | ||
| 57 | func Unmarshal(b []byte) (Session, error) { | 39 | func Unmarshal(b []byte) (Session, error) { | ||
| @@ -146,6 +128,61 @@ func (w *payloadWriter) putStrings(ss []string) error { | |||||
| 146 | return nil | 128 | return nil | ||
| 147 | } | 129 | } | ||
| 148 | 130 | ||||
| 131 | + | func writeNode(w *payloadWriter, n *layout.Node) error { | |||
| 132 | + | if n == nil { | |||
| 133 | + | w.putUint8(manifestNodeNil) | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | switch n.Kind { | |||
| 137 | + | case layout.Leaf: | |||
| 138 | + | w.putUint8(manifestNodeLeaf) | |||
| 139 | + | return w.putString(string(n.Leaf)) | |||
| 140 | + | case layout.Split: | |||
| 141 | + | w.putUint8(manifestNodeSplit) | |||
| 142 | + | w.putUint8(uint8(n.Dir)) | |||
| 143 | + | return writeChildren(w, n.Children) | |||
| 144 | + | case layout.Stack: | |||
| 145 | + | w.putUint8(manifestNodeStack) | |||
| 146 | + | if err := w.putString(string(n.Expanded)); err != nil { | |||
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | return writeChildren(w, n.Children) | |||
| 150 | + | default: | |||
| 151 | + | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |||
| 156 | + | if len(children) > math.MaxUint16 { | |||
| 157 | + | return fmt.Errorf("%w: too many children", ErrInvalidData) | |||
| 158 | + | } | |||
| 159 | + | w.putUint16(uint16(len(children))) | |||
| 160 | + | for _, c := range children { | |||
| 161 | + | if err := writeNode(w, c); err != nil { | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | return nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func writeProcess(w *payloadWriter, p *Process) error { | |||
| 169 | + | if p == nil { | |||
| 170 | + | w.putUint8(processAbsent) | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | if len(p.Argv) == 0 { | |||
| 174 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 175 | + | } | |||
| 176 | + | w.putUint8(processPresent) | |||
| 177 | + | if err := w.putStrings(p.Argv); err != nil { | |||
| 178 | + | return err | |||
| 179 | + | } | |||
| 180 | + | if err := w.putString(p.Strategy); err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | return w.putString(p.Opts.AgentSessionID) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 149 | type payloadReader struct{ b []byte } | 186 | type payloadReader struct{ b []byte } | ||
| 150 | 187 | ||||
| 151 | func (r *payloadReader) getUint8() (uint8, error) { | 188 | func (r *payloadReader) getUint8() (uint8, error) { | ||
| @@ -226,27 +263,6 @@ func (r *payloadReader) done() error { | |||||
| 226 | return nil | 263 | return nil | ||
| 227 | } | 264 | } | ||
| 228 | 265 | ||||
| 229 | - | func writeSession(w *payloadWriter, s Session) error { | |||
| 230 | - | if len(s.Tabs) > math.MaxUint16 { | |||
| 231 | - | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |||
| 232 | - | } | |||
| 233 | - | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |||
| 234 | - | return err | |||
| 235 | - | } | |||
| 236 | - | if err := w.putString(s.Name); err != nil { | |||
| 237 | - | return err | |||
| 238 | - | } | |||
| 239 | - | w.putUint64(s.CreatedAt) | |||
| 240 | - | w.putUint16(s.Active) | |||
| 241 | - | w.putUint16(uint16(len(s.Tabs))) | |||
| 242 | - | for _, t := range s.Tabs { | |||
| 243 | - | if err := writeTab(w, t); err != nil { | |||
| 244 | - | return err | |||
| 245 | - | } | |||
| 246 | - | } | |||
| 247 | - | return nil | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | func readSession(r *payloadReader) (Session, error) { | 266 | func readSession(r *payloadReader) (Session, error) { | ||
| 251 | name, err := r.getString() | 267 | name, err := r.getString() | ||
| 252 | if err != nil { | 268 | if err != nil { | ||
| @@ -278,35 +294,6 @@ func readSession(r *payloadReader) (Session, error) { | |||||
| 278 | return s, nil | 294 | return s, nil | ||
| 279 | } | 295 | } | ||
| 280 | 296 | ||||
| 281 | - | func writeTab(w *payloadWriter, t Tab) error { | |||
| 282 | - | if err := w.putString(t.StableID); err != nil { | |||
| 283 | - | return err | |||
| 284 | - | } | |||
| 285 | - | w.putUint16(t.Cols) | |||
| 286 | - | w.putUint16(t.Rows) | |||
| 287 | - | w.putUint64(t.NextPaneID) | |||
| 288 | - | if err := w.putString(string(t.Focus)); err != nil { | |||
| 289 | - | return err | |||
| 290 | - | } | |||
| 291 | - | var root *layout.Node | |||
| 292 | - | if t.Tree != nil { | |||
| 293 | - | root = t.Tree.Root | |||
| 294 | - | } | |||
| 295 | - | if err := writeNode(w, root); err != nil { | |||
| 296 | - | return err | |||
| 297 | - | } | |||
| 298 | - | if len(t.Panes) > math.MaxUint16 { | |||
| 299 | - | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |||
| 300 | - | } | |||
| 301 | - | w.putUint16(uint16(len(t.Panes))) | |||
| 302 | - | for _, p := range t.Panes { | |||
| 303 | - | if err := writePane(w, p); err != nil { | |||
| 304 | - | return err | |||
| 305 | - | } | |||
| 306 | - | } | |||
| 307 | - | return nil | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | func readTab(r *payloadReader) (Tab, error) { | 297 | func readTab(r *payloadReader) (Tab, error) { | ||
| 311 | stableID, err := r.getString() | 298 | stableID, err := r.getString() | ||
| 312 | if err != nil { | 299 | if err != nil { | ||
| @@ -347,43 +334,6 @@ func readTab(r *payloadReader) (Tab, error) { | |||||
| 347 | return t, nil | 334 | return t, nil | ||
| 348 | } | 335 | } | ||
| 349 | 336 | ||||
| 350 | - | func writeNode(w *payloadWriter, n *layout.Node) error { | |||
| 351 | - | if n == nil { | |||
| 352 | - | w.putUint8(manifestNodeNil) | |||
| 353 | - | return nil | |||
| 354 | - | } | |||
| 355 | - | switch n.Kind { | |||
| 356 | - | case layout.Leaf: | |||
| 357 | - | w.putUint8(manifestNodeLeaf) | |||
| 358 | - | return w.putString(string(n.Leaf)) | |||
| 359 | - | case layout.Split: | |||
| 360 | - | w.putUint8(manifestNodeSplit) | |||
| 361 | - | w.putUint8(uint8(n.Dir)) | |||
| 362 | - | return writeChildren(w, n.Children) | |||
| 363 | - | case layout.Stack: | |||
| 364 | - | w.putUint8(manifestNodeStack) | |||
| 365 | - | if err := w.putString(string(n.Expanded)); err != nil { | |||
| 366 | - | return err | |||
| 367 | - | } | |||
| 368 | - | return writeChildren(w, n.Children) | |||
| 369 | - | default: | |||
| 370 | - | return fmt.Errorf("%w: unknown node kind", ErrInvalidData) | |||
| 371 | - | } | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | - | func writeChildren(w *payloadWriter, children []*layout.Node) error { | |||
| 375 | - | if len(children) > math.MaxUint16 { | |||
| 376 | - | return fmt.Errorf("%w: too many children", ErrInvalidData) | |||
| 377 | - | } | |||
| 378 | - | w.putUint16(uint16(len(children))) | |||
| 379 | - | for _, c := range children { | |||
| 380 | - | if err := writeNode(w, c); err != nil { | |||
| 381 | - | return err | |||
| 382 | - | } | |||
| 383 | - | } | |||
| 384 | - | return nil | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | func readNode(r *payloadReader) (*layout.Node, error) { | 337 | func readNode(r *payloadReader) (*layout.Node, error) { | ||
| 388 | kind, err := r.getUint8() | 338 | kind, err := r.getUint8() | ||
| 389 | if err != nil { | 339 | if err != nil { | ||
| @@ -439,40 +389,6 @@ func readChildren(r *payloadReader) ([]*layout.Node, error) { | |||||
| 439 | return out, nil | 389 | return out, nil | ||
| 440 | } | 390 | } | ||
| 441 | 391 | ||||
| 442 | - | func writePane(w *payloadWriter, p Pane) error { | |||
| 443 | - | if err := w.putString(string(p.ID)); err != nil { | |||
| 444 | - | return err | |||
| 445 | - | } | |||
| 446 | - | if err := w.putString(p.StableID); err != nil { | |||
| 447 | - | return err | |||
| 448 | - | } | |||
| 449 | - | if err := w.putString(p.Cwd); err != nil { | |||
| 450 | - | return err | |||
| 451 | - | } | |||
| 452 | - | if len(p.SealedChunks) > maxSnapshotObjects { | |||
| 453 | - | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |||
| 454 | - | } | |||
| 455 | - | w.putUint32(uint32(len(p.SealedChunks))) | |||
| 456 | - | for _, blob := range p.SealedChunks { | |||
| 457 | - | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |||
| 458 | - | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |||
| 459 | - | } | |||
| 460 | - | w.putUint32(uint32(len(blob))) | |||
| 461 | - | w.b = append(w.b, blob...) | |||
| 462 | - | } | |||
| 463 | - | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |||
| 464 | - | return fmt.Errorf("%w: tail length", ErrInvalidData) | |||
| 465 | - | } | |||
| 466 | - | w.putUint32(uint32(len(p.Tail))) | |||
| 467 | - | w.b = append(w.b, p.Tail...) | |||
| 468 | - | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |||
| 469 | - | return fmt.Errorf("%w: visible length", ErrInvalidData) | |||
| 470 | - | } | |||
| 471 | - | w.putUint32(uint32(len(p.Visible))) | |||
| 472 | - | w.b = append(w.b, p.Visible...) | |||
| 473 | - | return writeProcess(w, p.Process) | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | func readPane(r *payloadReader) (Pane, error) { | 392 | func readPane(r *payloadReader) (Pane, error) { | ||
| 477 | id, err := r.getString() | 393 | id, err := r.getString() | ||
| 478 | if err != nil { | 394 | if err != nil { | ||
| @@ -514,24 +430,6 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 514 | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | 430 | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil | ||
| 515 | } | 431 | } | ||
| 516 | 432 | ||||
| 517 | - | func writeProcess(w *payloadWriter, p *Process) error { | |||
| 518 | - | if p == nil { | |||
| 519 | - | w.putUint8(processAbsent) | |||
| 520 | - | return nil | |||
| 521 | - | } | |||
| 522 | - | if len(p.Argv) == 0 { | |||
| 523 | - | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 524 | - | } | |||
| 525 | - | w.putUint8(processPresent) | |||
| 526 | - | if err := w.putStrings(p.Argv); err != nil { | |||
| 527 | - | return err | |||
| 528 | - | } | |||
| 529 | - | if err := w.putString(p.Strategy); err != nil { | |||
| 530 | - | return err | |||
| 531 | - | } | |||
| 532 | - | return w.putString(p.Opts.AgentSessionID) | |||
| 533 | - | } | |||
| 534 | - | ||||
| 535 | func readProcess(r *payloadReader) (*Process, error) { | 433 | func readProcess(r *payloadReader) (*Process, error) { | ||
| 536 | present, err := r.getUint8() | 434 | present, err := r.getUint8() | ||
| 537 | if err != nil { | 435 | if err != nil { | ||
A internal/usecase/snapshot/codec_encoder_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "fmt" | |
| 6 | + | "hash/crc32" | |
| 7 | + | "math" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // marshalTest is a test-only v3 encoder. Production retains only the decoder | |
| 13 | + | // required by the one-time legacy import. | |
| 14 | + | func marshalTest(s Session) ([]byte, error) { | |
| 15 | + | var w payloadWriter | |
| 16 | + | if err := writeSession(&w, s); err != nil { | |
| 17 | + | return nil, err | |
| 18 | + | } | |
| 19 | + | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |
| 20 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |
| 21 | + | } | |
| 22 | + | out := make([]byte, 16+len(w.b)) | |
| 23 | + | copy(out[:4], magic) | |
| 24 | + | binary.BigEndian.PutUint16(out[4:6], version) | |
| 25 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |
| 26 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |
| 27 | + | copy(out[16:], w.b) | |
| 28 | + | return out, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func writeSession(w *payloadWriter, s Session) error { | |
| 32 | + | if len(s.Tabs) > math.MaxUint16 { | |
| 33 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |
| 34 | + | } | |
| 35 | + | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | if err := w.putString(s.Name); err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | w.putUint64(s.CreatedAt) | |
| 42 | + | w.putUint16(s.Active) | |
| 43 | + | w.putUint16(uint16(len(s.Tabs))) | |
| 44 | + | for _, t := range s.Tabs { | |
| 45 | + | if err := writeTab(w, t); err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func writeTab(w *payloadWriter, t Tab) error { | |
| 53 | + | if err := w.putString(t.StableID); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | w.putUint16(t.Cols) | |
| 57 | + | w.putUint16(t.Rows) | |
| 58 | + | w.putUint64(t.NextPaneID) | |
| 59 | + | if err := w.putString(string(t.Focus)); err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | var root *layout.Node | |
| 63 | + | if t.Tree != nil { | |
| 64 | + | root = t.Tree.Root | |
| 65 | + | } | |
| 66 | + | if err := writeNode(w, root); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | if len(t.Panes) > math.MaxUint16 { | |
| 70 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |
| 71 | + | } | |
| 72 | + | w.putUint16(uint16(len(t.Panes))) | |
| 73 | + | for _, p := range t.Panes { | |
| 74 | + | if err := writePane(w, p); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func writePane(w *payloadWriter, p Pane) error { | |
| 82 | + | if err := w.putString(string(p.ID)); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | if err := w.putString(p.StableID); err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | if err := w.putString(p.Cwd); err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | if len(p.SealedChunks) > maxSnapshotObjects { | |
| 92 | + | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |
| 93 | + | } | |
| 94 | + | w.putUint32(uint32(len(p.SealedChunks))) | |
| 95 | + | for _, blob := range p.SealedChunks { | |
| 96 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |
| 97 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |
| 98 | + | } | |
| 99 | + | w.putUint32(uint32(len(blob))) | |
| 100 | + | w.b = append(w.b, blob...) | |
| 101 | + | } | |
| 102 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |
| 103 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) | |
| 104 | + | } | |
| 105 | + | w.putUint32(uint32(len(p.Tail))) | |
| 106 | + | w.b = append(w.b, p.Tail...) | |
| 107 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |
| 108 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |
| 109 | + | } | |
| 110 | + | w.putUint32(uint32(len(p.Visible))) | |
| 111 | + | w.b = append(w.b, p.Visible...) | |
| 112 | + | return writeProcess(w, p.Process) | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "fmt" | |||
| 6 | + | "hash/crc32" | |||
| 7 | + | "math" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // marshalTest is a test-only v3 encoder. Production retains only the decoder | |||
| 13 | + | // required by the one-time legacy import. | |||
| 14 | + | func marshalTest(s Session) ([]byte, error) { | |||
| 15 | + | var w payloadWriter | |||
| 16 | + | if err := writeSession(&w, s); err != nil { | |||
| 17 | + | return nil, err | |||
| 18 | + | } | |||
| 19 | + | if len(w.b) > maxDecodedBodySize || len(w.b) > math.MaxUint32 { | |||
| 20 | + | return nil, fmt.Errorf("%w: body too large", ErrInvalidData) | |||
| 21 | + | } | |||
| 22 | + | out := make([]byte, 16+len(w.b)) | |||
| 23 | + | copy(out[:4], magic) | |||
| 24 | + | binary.BigEndian.PutUint16(out[4:6], version) | |||
| 25 | + | binary.BigEndian.PutUint32(out[8:12], uint32(len(w.b))) | |||
| 26 | + | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(w.b)) | |||
| 27 | + | copy(out[16:], w.b) | |||
| 28 | + | return out, nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func writeSession(w *payloadWriter, s Session) error { | |||
| 32 | + | if len(s.Tabs) > math.MaxUint16 { | |||
| 33 | + | return fmt.Errorf("%w: too many tabs", ErrInvalidData) | |||
| 34 | + | } | |||
| 35 | + | if err := validateActive(s.Active, len(s.Tabs)); err != nil { | |||
| 36 | + | return err | |||
| 37 | + | } | |||
| 38 | + | if err := w.putString(s.Name); err != nil { | |||
| 39 | + | return err | |||
| 40 | + | } | |||
| 41 | + | w.putUint64(s.CreatedAt) | |||
| 42 | + | w.putUint16(s.Active) | |||
| 43 | + | w.putUint16(uint16(len(s.Tabs))) | |||
| 44 | + | for _, t := range s.Tabs { | |||
| 45 | + | if err := writeTab(w, t); err != nil { | |||
| 46 | + | return err | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func writeTab(w *payloadWriter, t Tab) error { | |||
| 53 | + | if err := w.putString(t.StableID); err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | w.putUint16(t.Cols) | |||
| 57 | + | w.putUint16(t.Rows) | |||
| 58 | + | w.putUint64(t.NextPaneID) | |||
| 59 | + | if err := w.putString(string(t.Focus)); err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | var root *layout.Node | |||
| 63 | + | if t.Tree != nil { | |||
| 64 | + | root = t.Tree.Root | |||
| 65 | + | } | |||
| 66 | + | if err := writeNode(w, root); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | if len(t.Panes) > math.MaxUint16 { | |||
| 70 | + | return fmt.Errorf("%w: too many panes", ErrInvalidData) | |||
| 71 | + | } | |||
| 72 | + | w.putUint16(uint16(len(t.Panes))) | |||
| 73 | + | for _, p := range t.Panes { | |||
| 74 | + | if err := writePane(w, p); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func writePane(w *payloadWriter, p Pane) error { | |||
| 82 | + | if err := w.putString(string(p.ID)); err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | if err := w.putString(p.StableID); err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | if err := w.putString(p.Cwd); err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | if len(p.SealedChunks) > maxSnapshotObjects { | |||
| 92 | + | return fmt.Errorf("%w: too many sealed chunks", ErrInvalidData) | |||
| 93 | + | } | |||
| 94 | + | w.putUint32(uint32(len(p.SealedChunks))) | |||
| 95 | + | for _, blob := range p.SealedChunks { | |||
| 96 | + | if len(blob) == 0 || len(blob) > math.MaxUint32 { | |||
| 97 | + | return fmt.Errorf("%w: sealed chunk length", ErrInvalidData) | |||
| 98 | + | } | |||
| 99 | + | w.putUint32(uint32(len(blob))) | |||
| 100 | + | w.b = append(w.b, blob...) | |||
| 101 | + | } | |||
| 102 | + | if len(p.Tail) == 0 || len(p.Tail) > math.MaxUint32 { | |||
| 103 | + | return fmt.Errorf("%w: tail length", ErrInvalidData) | |||
| 104 | + | } | |||
| 105 | + | w.putUint32(uint32(len(p.Tail))) | |||
| 106 | + | w.b = append(w.b, p.Tail...) | |||
| 107 | + | if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 { | |||
| 108 | + | return fmt.Errorf("%w: visible length", ErrInvalidData) | |||
| 109 | + | } | |||
| 110 | + | w.putUint32(uint32(len(p.Visible))) | |||
| 111 | + | w.b = append(w.b, p.Visible...) | |||
| 112 | + | return writeProcess(w, p.Process) | |||
| 113 | + | } | |||
M internal/usecase/snapshot/codec_test.go +13 -13
| @@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||
| 32 | 32 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | |
| 33 | 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | |
| 34 | 34 | }}}} | |
| 35 | - | encoded, err := Marshal(want) | |
| 35 | + | encoded, err := marshalTest(want) | |
| 36 | 36 | if err != nil { | |
| 37 | 37 | t.Fatal(err) | |
| 38 | 38 | } | |
| @@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||
| 86 | 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | |
| 87 | 87 | } { | |
| 88 | 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | |
| 89 | - | _, err := Marshal(tc.s) | |
| 89 | + | _, err := marshalTest(tc.s) | |
| 90 | 90 | if tc.valid { | |
| 91 | 91 | requireNoError(t, err) | |
| 92 | 92 | return | |
| 93 | 93 | } | |
| 94 | 94 | if !errors.Is(err, ErrInvalidData) { | |
| 95 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 95 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) | |
| 96 | 96 | } | |
| 97 | 97 | }) | |
| 98 | 98 | } | |
| @@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin | |||
| 153 | 153 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | |
| 154 | 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | |
| 155 | 155 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 156 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 156 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 157 | 157 | if err != nil { | |
| 158 | 158 | t.Fatal(err) | |
| 159 | 159 | } | |
| @@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 174 | 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | |
| 175 | 175 | } { | |
| 176 | 176 | t.Run(tc.name, func(t *testing.T) { | |
| 177 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | |
| 177 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | |
| 178 | 178 | requireNoError(t, err) | |
| 179 | 179 | reject(t, data, ErrInvalidData) | |
| 180 | 180 | }) | |
| @@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||
| 184 | 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |
| 185 | 185 | sealed, tail := historyBlobs(t, nil) | |
| 186 | 186 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 187 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 187 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | |
| 188 | 188 | if err != nil { | |
| 189 | 189 | t.Fatal(err) | |
| 190 | 190 | } | |
| @@ -216,7 +216,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 216 | 216 | requireNoError(t, err) | |
| 217 | 217 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | |
| 218 | 218 | requireNoError(t, err) | |
| 219 | - | valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |
| 219 | + | valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | |
| 220 | 220 | requireNoError(t, err) | |
| 221 | 221 | roundTrip, err := Unmarshal(valid) | |
| 222 | 222 | requireNoError(t, err) | |
| @@ -234,7 +234,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||
| 234 | 234 | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | |
| 235 | 235 | } { | |
| 236 | 236 | t.Run(tc.name, func(t *testing.T) { | |
| 237 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 237 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 238 | 238 | requireNoError(t, err) | |
| 239 | 239 | reject(t, data, ErrInvalidData) | |
| 240 | 240 | }) | |
| @@ -259,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||
| 259 | 259 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | |
| 260 | 260 | } { | |
| 261 | 261 | t.Run(tc.name, func(t *testing.T) { | |
| 262 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 262 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) | |
| 263 | 263 | requireNoError(t, err) | |
| 264 | 264 | got, err := Unmarshal(data) | |
| 265 | 265 | requireNoError(t, err) | |
| @@ -286,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||
| 286 | 286 | }, | |
| 287 | 287 | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | |
| 288 | 288 | }} | |
| 289 | - | data, err := Marshal(want) | |
| 289 | + | data, err := marshalTest(want) | |
| 290 | 290 | requireNoError(t, err) | |
| 291 | 291 | got, err := Unmarshal(data) | |
| 292 | 292 | requireNoError(t, err) | |
| @@ -300,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | 302 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 303 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{ | |
| 303 | + | _, err := marshalTest(Session{Name: "s", Tabs: []Tab{ | |
| 304 | 304 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | |
| 305 | 305 | }}) | |
| 306 | 306 | if !errors.Is(err, ErrInvalidData) { | |
| 307 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 307 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) | |
| 308 | 308 | } | |
| 309 | 309 | } | |
| 310 | 310 | ||
| @@ -354,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||
| 354 | 354 | for i := range tabs { | |
| 355 | 355 | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | |
| 356 | 356 | } | |
| 357 | - | data, err := Marshal(Session{Name: "over-budget", Tabs: tabs}) | |
| 357 | + | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) | |
| 358 | 358 | requireNoError(t, err) | |
| 359 | 359 | ||
| 360 | 360 | allocs := testing.AllocsPerRun(3, func() { | |
| @@ -32,7 +32,7 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) { | |||||
| 32 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | 32 | {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, | ||
| 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | 33 | {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2}, | ||
| 34 | }}}} | 34 | }}}} | ||
| 35 | - | encoded, err := Marshal(want) | 35 | + | encoded, err := marshalTest(want) |
| 36 | if err != nil { | 36 | if err != nil { | ||
| 37 | t.Fatal(err) | 37 | t.Fatal(err) | ||
| 38 | } | 38 | } | ||
| @@ -86,13 +86,13 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) { | |||||
| 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | 86 | {name: "nonempty tabs active past end", s: Session{Active: 1, Tabs: []Tab{validTab}}}, | ||
| 87 | } { | 87 | } { | ||
| 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | 88 | t.Run("marshal "+tc.name, func(t *testing.T) { | ||
| 89 | - | _, err := Marshal(tc.s) | 89 | + | _, err := marshalTest(tc.s) |
| 90 | if tc.valid { | 90 | if tc.valid { | ||
| 91 | requireNoError(t, err) | 91 | requireNoError(t, err) | ||
| 92 | return | 92 | return | ||
| 93 | } | 93 | } | ||
| 94 | if !errors.Is(err, ErrInvalidData) { | 94 | if !errors.Is(err, ErrInvalidData) { | ||
| 95 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | 95 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) |
| 96 | } | 96 | } | ||
| 97 | }) | 97 | }) | ||
| 98 | } | 98 | } | ||
| @@ -153,7 +153,7 @@ func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testin | |||||
| 153 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | 153 | func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) { | ||
| 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | 154 | sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}}) | ||
| 155 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 155 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 156 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | 156 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) |
| 157 | if err != nil { | 157 | if err != nil { | ||
| 158 | t.Fatal(err) | 158 | t.Fatal(err) | ||
| 159 | } | 159 | } | ||
| @@ -174,7 +174,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||||
| 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | 174 | {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}}, | ||
| 175 | } { | 175 | } { | ||
| 176 | t.Run(tc.name, func(t *testing.T) { | 176 | t.Run(tc.name, func(t *testing.T) { | ||
| 177 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) | 177 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}}) |
| 178 | requireNoError(t, err) | 178 | requireNoError(t, err) | ||
| 179 | reject(t, data, ErrInvalidData) | 179 | reject(t, data, ErrInvalidData) | ||
| 180 | }) | 180 | }) | ||
| @@ -184,7 +184,7 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) { | |||||
| 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | 184 | func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | ||
| 185 | sealed, tail := historyBlobs(t, nil) | 185 | sealed, tail := historyBlobs(t, nil) | ||
| 186 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | 186 | visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | ||
| 187 | - | encoded, err := Marshal(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) | 187 | + | encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}) |
| 188 | if err != nil { | 188 | if err != nil { | ||
| 189 | t.Fatal(err) | 189 | t.Fatal(err) | ||
| 190 | } | 190 | } | ||
| @@ -216,7 +216,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 216 | requireNoError(t, err) | 216 | requireNoError(t, err) | ||
| 217 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | 217 | fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView()) | ||
| 218 | requireNoError(t, err) | 218 | requireNoError(t, err) | ||
| 219 | - | valid, err := Marshal(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) | 219 | + | valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}}) |
| 220 | requireNoError(t, err) | 220 | requireNoError(t, err) | ||
| 221 | roundTrip, err := Unmarshal(valid) | 221 | roundTrip, err := Unmarshal(valid) | ||
| 222 | requireNoError(t, err) | 222 | requireNoError(t, err) | ||
| @@ -234,7 +234,7 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |||||
| 234 | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | 234 | {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}}, | ||
| 235 | } { | 235 | } { | ||
| 236 | t.Run(tc.name, func(t *testing.T) { | 236 | t.Run(tc.name, func(t *testing.T) { | ||
| 237 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | 237 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) |
| 238 | requireNoError(t, err) | 238 | requireNoError(t, err) | ||
| 239 | reject(t, data, ErrInvalidData) | 239 | reject(t, data, ErrInvalidData) | ||
| 240 | }) | 240 | }) | ||
| @@ -259,7 +259,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) { | |||||
| 259 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | 259 | {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}}, | ||
| 260 | } { | 260 | } { | ||
| 261 | t.Run(tc.name, func(t *testing.T) { | 261 | t.Run(tc.name, func(t *testing.T) { | ||
| 262 | - | data, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}}) | 262 | + | data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}}) |
| 263 | requireNoError(t, err) | 263 | requireNoError(t, err) | ||
| 264 | got, err := Unmarshal(data) | 264 | got, err := Unmarshal(data) | ||
| 265 | requireNoError(t, err) | 265 | requireNoError(t, err) | ||
| @@ -286,7 +286,7 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||||
| 286 | }, | 286 | }, | ||
| 287 | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | 287 | {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}}, | ||
| 288 | }} | 288 | }} | ||
| 289 | - | data, err := Marshal(want) | 289 | + | data, err := marshalTest(want) |
| 290 | requireNoError(t, err) | 290 | requireNoError(t, err) | ||
| 291 | got, err := Unmarshal(data) | 291 | got, err := Unmarshal(data) | ||
| 292 | requireNoError(t, err) | 292 | requireNoError(t, err) | ||
| @@ -300,11 +300,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) { | |||||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | 302 | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | ||
| 303 | - | _, err := Marshal(Session{Name: "s", Tabs: []Tab{ | 303 | + | _, err := marshalTest(Session{Name: "s", Tabs: []Tab{ |
| 304 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | 304 | {Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}, | ||
| 305 | }}) | 305 | }}) | ||
| 306 | if !errors.Is(err, ErrInvalidData) { | 306 | if !errors.Is(err, ErrInvalidData) { | ||
| 307 | - | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | 307 | + | t.Fatalf("marshalTest() error = %v, want %v", err, ErrInvalidData) |
| 308 | } | 308 | } | ||
| 309 | } | 309 | } | ||
| 310 | 310 | ||||
| @@ -354,7 +354,7 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) { | |||||
| 354 | for i := range tabs { | 354 | for i := range tabs { | ||
| 355 | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | 355 | tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}} | ||
| 356 | } | 356 | } | ||
| 357 | - | data, err := Marshal(Session{Name: "over-budget", Tabs: tabs}) | 357 | + | data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs}) |
| 358 | requireNoError(t, err) | 358 | requireNoError(t, err) | ||
| 359 | 359 | ||||
| 360 | allocs := testing.AllocsPerRun(3, func() { | 360 | allocs := testing.AllocsPerRun(3, func() { | ||
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
| @@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||
| 33 | 33 | }, | |
| 34 | 34 | } { | |
| 35 | 35 | t.Run(tt.name, func(t *testing.T) { | |
| 36 | - | encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | |
| 36 | + | encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | |
| 37 | 37 | if err != nil { | |
| 38 | 38 | t.Fatalf("Marshal: %v", err) | |
| 39 | 39 | } | |
| @@ -33,7 +33,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T | |||||
| 33 | }, | 33 | }, | ||
| 34 | } { | 34 | } { | ||
| 35 | t.Run(tt.name, func(t *testing.T) { | 35 | t.Run(tt.name, func(t *testing.T) { | ||
| 36 | - | encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) | 36 | + | encoded, err := marshalTest(Session{Name: "hostile", Tabs: []Tab{tt.tab}}) |
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | t.Fatalf("Marshal: %v", err) | 38 | t.Fatalf("Marshal: %v", err) | ||
| 39 | } | 39 | } | ||