diff --git a/pkg/core/filesystem_articles.go b/pkg/core/filesystem_articles.go index b91de39..25f5437 100644 --- a/pkg/core/filesystem_articles.go +++ b/pkg/core/filesystem_articles.go @@ -2,6 +2,8 @@ package core import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "io" "log/slog" @@ -155,19 +157,49 @@ func (r *FileSystemArticleRepository) saveImage(ctx context.Context, image *Imag } fullPath := filepath.Join(imageDir, filename) - file, err := os.Create(fullPath) + tempFile, err := createImageTempFile(imageDir, filename) if err != nil { - return "", fmt.Errorf("failed to create file %s: %w", fullPath, err) + return "", fmt.Errorf("failed to create temporary image file in %s: %w", imageDir, err) } - defer file.Close() + tempPath := tempFile.Name() + defer os.Remove(tempPath) - if _, err := io.Copy(file, asset.Body); err != nil { + if _, err := io.Copy(tempFile, asset.Body); err != nil { + _ = tempFile.Close() return "", fmt.Errorf("failed to write image to %s: %w", fullPath, err) } + if err := tempFile.Close(); err != nil { + return "", fmt.Errorf("failed to close image file %s: %w", fullPath, err) + } + if err := os.Rename(tempPath, fullPath); err != nil { + return "", fmt.Errorf("failed to finalize image file %s: %w", fullPath, err) + } return filename, nil } +func createImageTempFile(directory, filename string) (*os.File, error) { + const maxAttempts = 100 + + for range maxAttempts { + var randomBytes [16]byte + if _, err := rand.Read(randomBytes[:]); err != nil { + return nil, fmt.Errorf("generate random suffix: %w", err) + } + + path := filepath.Join(directory, "."+filename+"-"+hex.EncodeToString(randomBytes[:])) + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) + if err == nil { + return file, nil + } + if !os.IsExist(err) { + return nil, err + } + } + + return nil, fmt.Errorf("could not create a unique temporary file") +} + func resolveImageFilename(conf config.Config, image *Image, datetime time.Time) string { filename := conf.Output.Images.Filename if filename == "" { diff --git a/pkg/core/filesystem_articles_test.go b/pkg/core/filesystem_articles_test.go index b5c8121..5140dda 100644 --- a/pkg/core/filesystem_articles_test.go +++ b/pkg/core/filesystem_articles_test.go @@ -27,6 +27,60 @@ func (r *fakeImageRepository) Fetch(ctx context.Context, image *Image) (*ImageAs }, nil } +type failingImageRepository struct{} + +func (failingImageRepository) Fetch(context.Context, *Image) (*ImageAsset, error) { + return &ImageAsset{ + Body: io.NopCloser(&failingReader{}), + ContentType: "image/png", + }, nil +} + +type failingReader struct { + read bool +} + +func (r *failingReader) Read(p []byte) (int, error) { + if r.read { + return 0, assert.AnError + } + r.read = true + copy(p, "partial") + return len("partial"), nil +} + +func TestFileSystemArticleRepository_SaveImage_RemovesPartialFileOnFailure(t *testing.T) { + tempDir := t.TempDir() + conf := *config.NewConfig() + conf.Output.Images.Filename = "[:id].png" + repo := &FileSystemArticleRepository{imageRepo: failingImageRepository{}} + + _, err := repo.saveImage(context.Background(), NewImage("https://example.com/image.png", "", 0), tempDir, conf, time.Now()) + require.Error(t, err) + _, statErr := os.Stat(filepath.Join(tempDir, "0.png")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestFileSystemArticleRepository_SaveImage_RespectsProcessUmask(t *testing.T) { + tempDir := t.TempDir() + referencePath := filepath.Join(tempDir, "reference") + reference, err := os.OpenFile(referencePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) + require.NoError(t, err) + require.NoError(t, reference.Close()) + referenceInfo, err := os.Stat(referencePath) + require.NoError(t, err) + + conf := *config.NewConfig() + conf.Output.Images.Filename = "[:id].png" + repo := &FileSystemArticleRepository{imageRepo: &fakeImageRepository{contentType: "image/png", body: "png"}} + + filename, err := repo.saveImage(context.Background(), NewImage("https://example.com/image.png", "", 0), tempDir, conf, time.Now()) + require.NoError(t, err) + info, err := os.Stat(filepath.Join(tempDir, filename)) + require.NoError(t, err) + assert.Equal(t, referenceInfo.Mode().Perm(), info.Mode().Perm()) +} + func TestFileSystemArticleRepository_Save_RewritesImageURLs(t *testing.T) { tests := []struct { name string