Validate downloads before installing workspace files
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user