Validate downloads before installing workspace files

This commit is contained in:
2026-09-02 00:37:00 +00:00
parent ba858cc997
commit 39b2665d8b
13 changed files with 204 additions and 665 deletions

View File

@@ -114,22 +114,25 @@ func materializeVerifiedCachedAudio(ctx context.Context, req S3MaterializeReques
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(req.DestPath)); err != nil {
return "", false, fmt.Errorf("create audio destination directory: %w", err)
}
temporary, err := fileops.DownloadToSiblingTemp(req.DestPath, func(destination io.Writer) error {
temporaryPath, err := writeAudioTemp(req.DestPath, func(destination io.Writer) error {
_, err := io.Copy(destination, source)
return err
})
if err != nil {
return "", false, fmt.Errorf("copy verified cached audio: %w", err)
}
defer func() { _ = temporary.Cleanup() }()
checksum, size, err := temporaryChecksum(temporary)
defer func() { _ = os.Remove(temporaryPath) }()
checksum, size, err := checksumAudioTemp(temporaryPath)
if err != nil {
return "", false, err
}
if err := validateLocalAudio(temporaryPath, identity.Size); err != nil {
return "", false, nil
}
if size != identity.Size || checksum != identity.SHA256 {
return "", false, nil
}
if err := temporary.Install(filepath.Base(req.DestPath), fileops.WorkspaceFileMode); err != nil {
if err := fileops.InstallSiblingTempFile(temporaryPath, req.DestPath, fileops.WorkspaceFileMode); err != nil {
return "", false, fmt.Errorf("install verified cached audio: %w", err)
}
return checksum, true, nil
@@ -147,8 +150,8 @@ func cacheRelativePath(cacheRoot, cachePath string) (string, error) {
return normalized, nil
}
func temporaryChecksum(temporary *fileops.DownloadedTempFile) (string, int64, error) {
file, err := temporary.Open()
func checksumAudioTemp(path string) (string, int64, error) {
file, err := os.Open(path)
if err != nil {
return "", 0, fmt.Errorf("open cached audio copy: %w", err)
}

View File

@@ -70,12 +70,9 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
if spoolPath == "" {
return S3MaterializeResult{}, fmt.Errorf("spool path is required for s3 audio download")
}
if err := downloadObjectAtomic(ctx, req.Store, key, spoolPath); err != nil {
if err := downloadObjectAtomic(ctx, req.Store, key, spoolPath, req.Object.Size); err != nil {
return S3MaterializeResult{}, fmt.Errorf("download s3 audio object %q: %w", key, err)
}
if err := validateLocalAudio(spoolPath, req.Object.Size); err != nil {
return S3MaterializeResult{}, fmt.Errorf("validate downloaded audio %q: %w", spoolPath, err)
}
checksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, req.DestPath, fileops.WorkspaceFileMode)
if err != nil {
@@ -118,15 +115,47 @@ func validateLocalAudio(path string, expectedSize int64) error {
return nil
}
func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, destPath string) error {
if strings.TrimSpace(destPath) == "" {
return fmt.Errorf("destination path is required")
}
dir := filepath.Dir(destPath)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
return fileops.DownloadAndInstall(destPath, fileops.WorkspaceFileMode, func(destination io.Writer) error {
func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, destPath string, expectedSize int64) error {
temporaryPath, err := writeAudioTemp(destPath, func(destination io.Writer) error {
return storage.DownloadTo(ctx, store, key, destination)
})
if err != nil {
return err
}
defer func() { _ = os.Remove(temporaryPath) }()
if err := validateLocalAudio(temporaryPath, expectedSize); err != nil {
return fmt.Errorf("validate downloaded audio: %w", err)
}
if err := fileops.InstallSiblingTempFile(temporaryPath, destPath, fileops.WorkspaceFileMode); err != nil {
return err
}
return nil
}
func writeAudioTemp(destination string, write func(io.Writer) error) (string, error) {
if strings.TrimSpace(destination) == "" {
return "", fmt.Errorf("destination path is required")
}
if write == nil {
return "", fmt.Errorf("temporary audio writer is required")
}
dir := filepath.Dir(destination)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
temporary, err := os.CreateTemp(dir, "."+filepath.Base(destination)+".tmp-*")
if err != nil {
return "", fmt.Errorf("create sibling temporary file: %w", err)
}
temporaryPath := temporary.Name()
if err := write(temporary); err != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
return "", fmt.Errorf("write sibling temporary file: %w", err)
}
if err := temporary.Close(); err != nil {
_ = os.Remove(temporaryPath)
return "", fmt.Errorf("close sibling temporary file: %w", err)
}
return temporaryPath, nil
}

View File

@@ -288,6 +288,30 @@ func TestMaterializeS3AudioDownloadFailureLeavesDestinationMissing(t *testing.T)
assertMissing(t, req.DestPath)
}
func TestMaterializeS3AudioRejectsSizeMismatchBeforeReplacingSpool(t *testing.T) {
root := t.TempDir()
fake := &storage.FakeBackend{}
key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac"
fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio")})
req := testMaterializeRequest(t, root, fake, key, int64(len("audio")+1))
writeAudioTestFile(t, req.SpoolPath, "existing-audio")
_, err := MaterializeS3Audio(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "size") {
t.Fatalf("error = %v, want size validation failure", err)
}
assertFileEquals(t, req.SpoolPath, "existing-audio")
entries, err := os.ReadDir(filepath.Dir(req.SpoolPath))
if err != nil {
t.Fatalf("read spool directory: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".alice.flac.tmp-") {
t.Fatalf("unexpected temporary file residue: %q", entry.Name())
}
}
}
func testMaterializeRequest(t *testing.T, root string, fake *storage.FakeBackend, key string, size int64) S3MaterializeRequest {
t.Helper()
return S3MaterializeRequest{