Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 64 additions & 16 deletions app/api/controller/lfs/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,58 @@
package lfs

import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"strings"
"time"

"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/blob"
"github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"

"github.com/google/uuid"
"github.com/rs/zerolog/log"
)

const (
lfsTempPathFormat = "lfs/tmp/%s"
)

type UploadOut struct {
ObjectPath string `json:"object_path"`
ObjectPath string `json:"object_path"` //nolint:tagliatelle
}

// hashingReader wraps an io.Reader and calculates a hash while reading.
type hashingReader struct {
reader io.Reader
hasher hash.Hash
}

func newHashingReader(r io.Reader) *hashingReader {
return &hashingReader{
reader: r,
hasher: sha256.New(),
}
}

func (h *hashingReader) Read(p []byte) (int, error) {
n, err := h.reader.Read(p)
if n > 0 {
h.hasher.Write(p[:n])
}
return n, err
}

func (h *hashingReader) Sum() string {
return hex.EncodeToString(h.hasher.Sum(nil))
}

func (c *Controller) Upload(ctx context.Context,
Expand Down Expand Up @@ -61,28 +94,43 @@ func (c *Controller) Upload(ctx context.Context,
return nil, usererror.Conflict("LFS object already exists and cannot be modified")
}

expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing validation that pointer.OId actually has the sha256: prefix.

strings.TrimPrefix is a no-op when the prefix is absent — expectedHash would silently become the full OID string. The hash comparison on Line 113 would then always fail, and the uploaded temp file would be deleted. This masks the real problem (unsupported/malformed OID) behind a misleading "content hash doesn't match" error.

Proposed fix
-	expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")
+	if !strings.HasPrefix(pointer.OId, "sha256:") {
+		return nil, usererror.BadRequest("unsupported or malformed OID: expected sha256: prefix")
+	}
+	expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")
if !strings.HasPrefix(pointer.OId, "sha256:") {
return nil, usererror.BadRequest("unsupported or malformed OID: expected sha256: prefix")
}
expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/controller/lfs/upload.go` at line 97, Validate that pointer.OId
begins with the "sha256:" prefix before trimming: check
strings.HasPrefix(pointer.OId, "sha256:") and return/bubble an explicit error
(or BadRequest) if it does not, instead of calling strings.TrimPrefix blindly;
then compute expectedHash by trimming the prefix and proceed with the existing
hash comparison logic (referencing pointer.OId and expectedHash in upload
handling code) so malformed/unsupported OIDs produce a clear validation error
rather than a misleading "content hash doesn't match".


// Generate a unique temp path for staging the upload
tempPath := fmt.Sprintf(lfsTempPathFormat, uuid.NewString())
finalPath := getLFSObjectPath(pointer.OId)

// Stream the content to temp path while calculating hash
limitedReader := io.LimitReader(file, pointer.Size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AICR Agent (local Opus) · 🟡 MEDIUM · confidence 60

Hash is computed over truncated bytes, but a too-short upload is never rejected. io.LimitReader(file, pointer.Size) caps reads at pointer.Size, and the hash is taken over whatever bytes actually flow through hashReader. If the client sends fewer than pointer.Size bytes, the upload still streams to the temp path and the OID is verified against the partial content. The only thing that catches a corrupted/truncated body is the SHA-256 comparison — which is correct — so this is not a hash bypass. However, there is no explicit length check: a client that lies with a large pointer.Size but sends a short body that happens to hash to the claimed OID is impossible (collision), so integrity holds. No action strictly required, but note that the previous io.ReadAll path had identical semantics. Downgrade/ignore if size enforcement lives upstream.

content, err := io.ReadAll(limitedReader)
hashReader := newHashingReader(limitedReader)

err = c.blobStore.Upload(ctx, hashReader, tempPath)
if err != nil {
return nil, fmt.Errorf("failed to read uploaded content: %w", err)
return nil, fmt.Errorf("failed to upload file to temp path: %w", err)
}

hasher := sha256.New()
hasher.Write(content)
calculatedHash := hex.EncodeToString(hasher.Sum(nil))

expectedHash := strings.TrimPrefix(pointer.OId, "sha256:")

calculatedHash := hashReader.Sum()
if calculatedHash != expectedHash {
if deleteErr := c.blobStore.Delete(ctx, tempPath); deleteErr != nil {
if !errors.Is(deleteErr, blob.ErrNotFound) {
log.Ctx(ctx).Warn().Err(deleteErr).
Str("temp_path", tempPath).
Msg("failed to delete temp file after hash mismatch")
}
}
return nil, usererror.BadRequest("content hash doesn't match provided OID")
}

contentReader := bytes.NewReader(content)
objPath := getLFSObjectPath(pointer.OId)

err = c.blobStore.Upload(ctx, contentReader, objPath)
err = c.blobStore.Move(ctx, tempPath, finalPath)
if err != nil {
return nil, fmt.Errorf("failed to upload file: %w", err)
if deleteErr := c.blobStore.Delete(ctx, tempPath); deleteErr != nil {
if !errors.Is(deleteErr, blob.ErrNotFound) {
log.Ctx(ctx).Warn().Err(deleteErr).
Str("temp_path", tempPath).
Msg("failed to delete temp file after move failure")
}
}
return nil, fmt.Errorf("failed to move file to final path: %w", err)
}

now := time.Now()
Expand All @@ -101,6 +149,6 @@ func (c *Controller) Upload(ctx context.Context,
}

return &UploadOut{
ObjectPath: objPath,
ObjectPath: finalPath,
}, nil
}
30 changes: 30 additions & 0 deletions blob/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,33 @@ func (c *FileSystemStore) Download(_ context.Context, filePath string) (io.ReadC
}
return io.ReadCloser(file), nil
}

func (c *FileSystemStore) Move(_ context.Context, srcPath, dstPath string) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type "FileSystemStore" has both value and pointer receivers


(Go's FAQ)[https://go.dev/doc/faq#methods_on_values_or_pointers] recommends
that method receivers should be consistent. If some of the methods of the type
must have pointer receivers, the rest should too, so the method set is
consistent regardless of how the type is used. This is because value and
pointer receivers have different
method sets.

srcDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, srcPath)
dstDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, dstPath)

// Ensure destination directory exists
dstDir, _ := path.Split(dstDiskPath)
if _, err := os.Stat(dstDir); errors.Is(err, fs.ErrNotExist) {
if err = os.MkdirAll(dstDir, os.ModeDir|os.ModePerm); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
}

if err := os.Rename(srcDiskPath, dstDiskPath); err != nil {
return fmt.Errorf("failed to move file: %w", err)
}
return nil
}
Comment on lines +106 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Move does not return ErrNotFound for a missing source, unlike the GCS backend and the Delete method.

When the source file doesn't exist, os.Rename returns an *os.PathError wrapping fs.ErrNotExist, but this method doesn't translate it to blob.ErrNotFound. The GCS implementation (Lines 177-178 of blob/gcs.go) returns ErrNotFound when the source is missing. The Delete method in this same file (Lines 128-129) also correctly maps fs.ErrNotExistErrNotFound.

This inconsistency means callers cannot reliably use errors.Is(err, blob.ErrNotFound) across backends for Move.

Proposed fix
 	if err := os.Rename(srcDiskPath, dstDiskPath); err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			return ErrNotFound
+		}
 		return fmt.Errorf("failed to move file: %w", err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (c *FileSystemStore) Move(_ context.Context, srcPath, dstPath string) error {
srcDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, srcPath)
dstDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, dstPath)
// Ensure destination directory exists
dstDir, _ := path.Split(dstDiskPath)
if _, err := os.Stat(dstDir); errors.Is(err, fs.ErrNotExist) {
if err = os.MkdirAll(dstDir, os.ModeDir|os.ModePerm); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
}
if err := os.Rename(srcDiskPath, dstDiskPath); err != nil {
return fmt.Errorf("failed to move file: %w", err)
}
return nil
}
func (c *FileSystemStore) Move(_ context.Context, srcPath, dstPath string) error {
srcDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, srcPath)
dstDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, dstPath)
// Ensure destination directory exists
dstDir, _ := path.Split(dstDiskPath)
if _, err := os.Stat(dstDir); errors.Is(err, fs.ErrNotExist) {
if err = os.MkdirAll(dstDir, os.ModeDir|os.ModePerm); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
}
if err := os.Rename(srcDiskPath, dstDiskPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
return fmt.Errorf("failed to move file: %w", err)
}
return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blob/filesystem.go` around lines 106 - 122, The Move implementation on
FileSystemStore should translate a missing source into blob.ErrNotFound like
other backends; modify FileSystemStore.Move (look up srcDiskPath and
dstDiskPath) to check the error returned by os.Rename and, if errors.Is(err,
fs.ErrNotExist) (or unwrapping yields fs.ErrNotExist), return blob.ErrNotFound
instead of the wrapped *os.PathError; otherwise return the wrapped error as-is
so behavior matches the Delete method and the GCS backend.


func (c *FileSystemStore) Delete(_ context.Context, filePath string) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type "FileSystemStore" has both value and pointer receivers


(Go's FAQ)[https://go.dev/doc/faq#methods_on_values_or_pointers] recommends
that method receivers should be consistent. If some of the methods of the type
must have pointer receivers, the rest should too, so the method set is
consistent regardless of how the type is used. This is because value and
pointer receivers have different
method sets.

fileDiskPath := fmt.Sprintf(fileDiskPathFmt, c.basePath, filePath)

if err := os.Remove(fileDiskPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
161 changes: 161 additions & 0 deletions blob/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,164 @@ func TestFileSystemStore_Interface(t *testing.T) {

_ = store
}

func TestFileSystemStore_Move(t *testing.T) {
tempDir, err := os.MkdirTemp("", "blob-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)

store := &FileSystemStore{basePath: tempDir}
ctx := context.Background()

tests := []struct {
name string
srcPath string
dstPath string
content string
expectError bool
errorType error
}{
{
name: "move file same directory",
srcPath: "src.txt",
dstPath: "dst.txt",
content: "test content",
expectError: false,
},
{
name: "move file to nested directory",
srcPath: "src2.txt",
dstPath: "nested/dir/dst2.txt",
content: "nested content",
expectError: false,
},
{
name: "move non-existent file",
srcPath: "nonexistent.txt",
dstPath: "dst.txt",
content: "",
expectError: true,
},
Comment on lines +407 to +413

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing errorType check for the non-existent source move test.

The "move non-existent file" case only asserts that some error is returned but doesn't verify it's ErrNotFound. The analogous Delete test (Line 493) correctly sets errorType: ErrNotFound. Once the Move implementation is updated to return ErrNotFound for missing sources (see comment on blob/filesystem.go), this test should verify the error type too.

Proposed fix
 		{
 			name:        "move non-existent file",
 			srcPath:    "nonexistent.txt",
 			dstPath:    "dst.txt",
 			content:    "",
 			expectError: true,
+			errorType:   ErrNotFound,
 		},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
name: "move non-existent file",
srcPath: "nonexistent.txt",
dstPath: "dst.txt",
content: "",
expectError: true,
},
{
name: "move non-existent file",
srcPath: "nonexistent.txt",
dstPath: "dst.txt",
content: "",
expectError: true,
errorType: ErrNotFound,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@blob/filesystem_test.go` around lines 407 - 413, The test case "move
non-existent file" in filesystem_test.go currently only expects an error but
doesn't assert its type; update the test case struct for that entry to include
errorType: ErrNotFound and ensure the test assertion in the Move test checks for
that specific error type (same pattern used in the Delete test), i.e., when
calling Move verify returned error is ErrNotFound after the Move implementation
is changed to return ErrNotFound for missing sources.

}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Setup: create source file if content is provided
if test.content != "" {
srcFullPath := filepath.Join(tempDir, test.srcPath)
if err := os.WriteFile(srcFullPath, []byte(test.content), 0600); err != nil {
t.Fatalf("failed to create source file: %v", err)
}
}

err := store.Move(ctx, test.srcPath, test.dstPath)

if test.expectError {
if err == nil {
t.Error("expected error but got none")
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify source file no longer exists
srcFullPath := filepath.Join(tempDir, test.srcPath)
if _, err := os.Stat(srcFullPath); !os.IsNotExist(err) {
t.Error("source file should not exist after move")
}

// Verify destination file exists with correct content
dstFullPath := filepath.Join(tempDir, test.dstPath)
data, err := os.ReadFile(dstFullPath)
if err != nil {
t.Fatalf("failed to read destination file: %v", err)
}

if string(data) != test.content {
t.Errorf("expected content %q, got %q", test.content, string(data))
}
})
}
}

func TestFileSystemStore_Delete(t *testing.T) {
tempDir, err := os.MkdirTemp("", "blob-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)

store := &FileSystemStore{basePath: tempDir}
ctx := context.Background()

tests := []struct {
name string
filePath string
content string
expectError bool
errorType error
}{
{
name: "delete existing file",
filePath: "to-delete.txt",
content: "delete me",
expectError: false,
},
{
name: "delete nested file",
filePath: "nested/to-delete.txt",
content: "delete nested",
expectError: false,
},
{
name: "delete non-existent file",
filePath: "nonexistent.txt",
content: "",
expectError: true,
errorType: ErrNotFound,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Setup: create file if content is provided
if test.content != "" {
fullPath := filepath.Join(tempDir, test.filePath)
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expect directory permissions to be 0750 or less


Excessive permissions granted when creating a directory. This warning is
triggered whenever permission greater than 0750 is given.

t.Fatalf("failed to create directory: %v", err)
}
if err := os.WriteFile(fullPath, []byte(test.content), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
}

err := store.Delete(ctx, test.filePath)

if test.expectError {
if err == nil {
t.Error("expected error but got none")
}
if test.errorType != nil && !errors.Is(err, test.errorType) {
t.Errorf("expected error type %v, got %v", test.errorType, err)
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Verify file no longer exists
fullPath := filepath.Join(tempDir, test.filePath)
if _, err := os.Stat(fullPath); !os.IsNotExist(err) {
t.Error("file should not exist after delete")
}
})
}
}
41 changes: 41 additions & 0 deletions blob/gcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,47 @@ func (c *GCSStore) Download(ctx context.Context, filePath string) (io.ReadCloser
return rc, nil
}

func (c *GCSStore) Move(ctx context.Context, srcPath, dstPath string) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AICR Agent (local Opus) · 🟡 MEDIUM · confidence 80

Move is non-atomic on GCS (copy-then-delete), so a crash between copy and delete leaves a dangling temp object. Unlike the filesystem backend which uses os.Rename (atomic), GCSStore.Move copies then deletes. If the process crashes after CopierFrom().Run() succeeds but before srcObj.Delete, the final object is correctly written (good) but the temp object leaks. Combined with the warning-only delete handling, temp leakage is the consistent failure mode for the GCS backend. Same mitigation as above (lifecycle rule on lfs/tmp/). Calling out as a distinct durability/cleanup concern across backends per the refactor's stated goal.

gcsClient, err := c.getClient(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve latest client: %w", err)
}

bkt := gcsClient.Bucket(c.config.Bucket)
srcObj := bkt.Object(srcPath)
dstObj := bkt.Object(dstPath)

if _, err := dstObj.CopierFrom(srcObj).Run(ctx); err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return ErrNotFound
}
return fmt.Errorf("failed to copy file from %q to %q: %w", srcPath, dstPath, err)
}

if err := srcObj.Delete(ctx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AICR Agent (local Opus) · 🟡 MEDIUM · confidence 80

Successful-path temp object can leak silently on GCS. On the happy path the temp object is removed only as a side effect of Move. In GCSStore.Move, after a successful CopierFrom().Run(), a failure of srcObj.Delete(ctx) is logged as a warning and the function returns nil (gcs.go NEW:183-186). The caller treats the move as fully successful, so the temp object under lfs/tmp/<uuid> is never cleaned up and accumulates indefinitely (no GC/lifecycle is added in this PR). Recommend either configuring a bucket lifecycle rule on the lfs/tmp/ prefix, or surfacing the delete failure so an out-of-band cleanup can retry. Business impact: unbounded storage growth of orphaned temp blobs.

log.Ctx(ctx).Warn().Err(err).Msgf(
"failed to delete source file %q after successful copy to %q", srcPath, dstPath)
}

return nil
}

func (c *GCSStore) Delete(ctx context.Context, filePath string) error {
gcsClient, err := c.getClient(ctx)
if err != nil {
return fmt.Errorf("failed to retrieve latest client: %w", err)
}

bkt := gcsClient.Bucket(c.config.Bucket)
if err := bkt.Object(filePath).Delete(ctx); err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return ErrNotFound
}
return fmt.Errorf("failed to delete file %q: %w", filePath, err)
}
return nil
}

func createNewImpersonatedClient(ctx context.Context, cfg Config) (*storage.Client, error) {
// Use workload identity impersonation default credentials (GKE environment)
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
Expand Down
6 changes: 6 additions & 0 deletions blob/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,10 @@ type Store interface {

// Download returns a reader for a file in the blob store.
Download(ctx context.Context, filePath string) (io.ReadCloser, error)

// Move moves a file from srcPath to dstPath within the blob store.
Move(ctx context.Context, srcPath, dstPath string) error

// Delete removes a file from the blob store.
Delete(ctx context.Context, filePath string) error
}