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
64 changes: 61 additions & 3 deletions internal/helper/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ import (
)

// Returns whether this url should be handled by the Blob handler
// This is complicated because Blob is indicated by the trailing path, not the leading path.
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulling-a-layer
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-a-layer
// This is complicated because Blob is indicated by the trailing path, not the l
eading path.
Comment on lines +23 to +24
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix line wrapping issue in comment

There's a syntax error in the comment where "leading" is split across two lines. This breaks the code and causes compilation errors that affect other files.

-// This is complicated because Blob is indicated by the trailing path, not the l
-eading path.
+// This is complicated because Blob is indicated by the trailing path, not the leading path.
📝 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
// This is complicated because Blob is indicated by the trailing path, not the l
eading path.
// This is complicated because Blob is indicated by the trailing path, not the leading path.
🧰 Tools
🪛 golangci-lint (1.64.8)

24-24: expected declaration, found eading

(typecheck)

🤖 Prompt for AI Agents
In internal/helper/helper.go at lines 23 to 24, the comment has a line break
splitting the word "leading" into two parts, causing a syntax error. Fix this by
merging the split word into a single line without breaking it, ensuring the
entire comment is properly formatted and does not cause compilation errors.

Copy link

Choose a reason for hiding this comment

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

Line is not commented, resulting in an identifier at package scope and causing a syntax error that breaks compilation.

// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulli
ng-a-layer
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushi
ng-a-layer
func IsBlob(req *http.Request) bool {
elem := strings.Split(req.URL.Path, "/")
elem = elem[1:]
Expand Down Expand Up @@ -71,3 +74,58 @@ func IsV2(req *http.Request) bool {
}
return elems[len(elems)-1] == "v2"
}

// SemVerReplace replaces all underscores in a given string with plus signs.
// This function is primarily intended for use with semantic version strings
// where underscores might have been used in place of plus signs (e.g., for
// build metadata or pre-release identifiers). Standard Semantic Versioning
// (semver.org) specifies plus signs for build metadata (e.g., "1.0.0+20130313144700")
// and hyphens for pre-release identifiers (e.g., "1.0.0-alpha").
//
// Purpose:
// The main purpose of this function is to normalize version strings by converting
// any underscores to plus signs. This can be particularly useful when dealing with
// version strings from systems or sources that use underscores due to constraints
// (e.g., where '+' is a special character) or by convention for information that
// semantically aligns with build metadata.
//
// When to use it:
// Use this function when you encounter version strings like "v1.2.3_build456" or
// "2.0.0_rc_1" and need to transform them into a format like "v1.2.3+build456" or
// "2.0.0+rc+1". This transformation is often a preparatory step before parsing
// the string with a semantic versioning library that strictly expects '+' for
// build metadata, or when aiming for a consistent display format for version information.
//
// Transformation Examples:
// - Input: "1.0.0_alpha"
// Output: "1.0.0+alpha"
// - Input: "v2.1.3_beta_build123" (handles multiple underscores)
// Output: "v2.1.3+beta+build123"
// - Input: "1.2.3" (string with no underscores)
// Output: "1.2.3" (string remains unchanged)
// - Input: "" (empty string)
// Output: "" (empty string remains unchanged)
//
// Semver Validation:
// This function does NOT perform validation of the overall semantic version string structure.
// For example, it does not check if the version string conforms to the MAJOR.MINOR.PATCH
// numerical format or other specific semver rules. Its sole responsibility is to
// replace every occurrence of the underscore character '_' with a plus sign '+'.
// For comprehensive semver parsing and validation, it is recommended to use a
// dedicated semver library on the string after this transformation, if necessary.
//
// Edge Cases Handled:
// - Multiple underscores: All occurrences of underscores are replaced.
// For instance, "1.0.0_alpha_snapshot" becomes "1.0.0+alpha+snapshot".
// - Empty string: If an empty string is provided, an empty string is returned.
// - String without underscores: If the string does not contain any underscores,
// it is returned as is.
func SemVerReplace(semver string) string {
// strings.ReplaceAll is efficient and handles edge cases gracefully:
// - If `semver` is an empty string, it returns an empty string.
// - If `semver` does not contain "_", it returns `semver` unchanged.
// - It replaces all occurrences of "_" with "+".
// Therefore, the original conditional check (if semver != "" && strings.Contains(semver, "_"))
// is not strictly necessary for correctness.
return strings.ReplaceAll(semver, "_", "+")
}
73 changes: 73 additions & 0 deletions internal/helper/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package helper

import (
"testing"
)

func TestSemVerReplace(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
{
name: "no underscores",
input: "1.0.0",
expected: "1.0.0",
},
{
name: "one underscore",
input: "1.0.0_alpha",
expected: "1.0.0+alpha",
},
{
name: "multiple underscores",
input: "1.0.0_alpha_build123",
expected: "1.0.0+alpha+build123",
},
{
name: "leading and trailing underscores",
input: "_1.0.0_",
expected: "+1.0.0+",
},
{
name: "only underscores",
input: "___",
expected: "+++",
},
{
name: "mixed case and underscores",
input: "v1.2.3_Rc1_candidate",
expected: "v1.2.3+Rc1+candidate",
},
{
name: "no underscores with v prefix",
input: "v2.3.4",
expected: "v2.3.4",
},
{
name: "already has plus (should not change)",
input: "1.0.0+beta",
expected: "1.0.0+beta",
},
{
name: "mixed plus and underscore",
input: "1.0.0_alpha+beta_rc1",
expected: "1.0.0+alpha+beta+rc1",
},
}

for _, tc := range testCases {
Copy link

Choose a reason for hiding this comment

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

The loop variable tc is captured by reference inside the sub-test closure. If t.Parallel is added later, sub-tests could run concurrently and observe the mutated tc value, leading to flaky tests. Create a new local variable (tc := tc) before calling t.Run to capture the current iteration value.

t.Run(tc.name, func(t *testing.T) {
actual := SemVerReplace(tc.input)
if actual != tc.expected {
t.Errorf("SemVerReplace(%q) = %q; want %q", tc.input, actual, tc.expected)
}
})
}
}
12 changes: 11 additions & 1 deletion internal/manifest/charts.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler"
"github.com/container-registry/helm-charts-oci-proxy/internal/errors"
"github.com/container-registry/helm-charts-oci-proxy/internal/helper"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"helm.sh/helm/v3/pkg/chart"
Expand Down Expand Up @@ -47,10 +48,19 @@ func (m *Manifests) prepareChart(ctx context.Context, repo string, reference str
m.log.Printf("searching index for %s with reference %s\n", chart, reference)
chartVer, err := index.Get(chart, reference)
if err != nil {
originalReference := reference // Store Original Reference
if m.config.Debug {
m.log.Printf("Chart lookup for '%s' with reference '%s' failed. Attempting again after converting underscores to plus signs in reference.", chart, originalReference)
}
reference = helper.SemVerReplace(originalReference) // Use originalReference for conversion
Copy link

Copilot AI May 22, 2025

Choose a reason for hiding this comment

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

This conversion step closely mirrors the logic in manifest.go. Consider consolidating the underscore-to-plus conversion into a shared helper to ensure consistency and ease future updates.

Suggested change
reference = helper.SemVerReplace(originalReference) // Use originalReference for conversion
reference = helper.SemVerReplace(originalReference) // Centralized underscore-to-plus conversion

Copilot uses AI. Check for mistakes.
chartVer, err = index.Get(chart, reference)
}
if err != nil {

return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf("Chart: %s version: %s not found: %v", chart, reference, err),
Message: fmt.Sprintf("Chart: %s version: %s not found. Attempted lookup with original reference and after converting underscores to plus signs. Last error: %v", chart, originalReference, err),
Copy link

Choose a reason for hiding this comment

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

originalReference is undefined here because it was declared inside the previous if-block and is out of scope, causing a compilation error.

}
Comment on lines 60 to 64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix potential undefined variable in error message

The variable originalReference might be undefined if the initial lookup doesn't fail but the retry fails. Ensure it's always defined before the error message.

 if err != nil {
+	originalReference := reference // Ensure originalReference is always defined
 	return &errors.RegError{
 		Status:  http.StatusNotFound,
 		Code:    "NOT FOUND",
 		Message: fmt.Sprintf("Chart: %s version: %s not found. Attempted lookup with original reference and after converting underscores to plus signs. Last error: %v", chart, originalReference, 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
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf("Chart: %s version: %s not found: %v", chart, reference, err),
Message: fmt.Sprintf("Chart: %s version: %s not found. Attempted lookup with original reference and after converting underscores to plus signs. Last error: %v", chart, originalReference, err),
}
if err != nil {
// Ensure originalReference is always defined before using it in the error message
originalReference := reference
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf(
"Chart: %s version: %s not found. Attempted lookup with original reference and after converting underscores to plus signs. Last error: %v",
chart, originalReference, err,
),
}
}
🧰 Tools
🪛 golangci-lint (1.64.8)

63-63: undefined: originalReference

(typecheck)

🤖 Prompt for AI Agents
In internal/manifest/charts.go around lines 60 to 64, the variable
originalReference used in the error message may be undefined if the initial
lookup succeeds but the retry fails. To fix this, ensure originalReference is
always assigned a value before this error return statement, either by
initializing it earlier or by capturing it from the first lookup attempt, so it
is defined regardless of which lookup fails.

}

Expand Down
41 changes: 30 additions & 11 deletions internal/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,19 @@ func (m *Manifests) Handle(resp http.ResponseWriter, req *http.Request) error {

ma, ok = c[target]
if !ok {
// we failed
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf("Chart prepare's result not found: %v, %v", repo, target),
originalTarget := target // Store original target
if m.config.Debug {
m.log.Printf("GET: Chart lookup failed for repo %s with target %s after prepareChart. Attempting again after converting underscores to plus signs.", repo, originalTarget)
}
target = helper.SemVerReplace(originalTarget) // Use originalTarget for conversion
ma, ok = c[target] // Attempt lookup with the new target
if !ok {
// we failed again
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf("GET: Chart prepare's result not found for repo %s. Tried target '%s' and after underscore conversion '%s'.", repo, originalTarget, target),
}
}
Comment on lines +163 to 176
Copy link

Copilot AI May 22, 2025

Choose a reason for hiding this comment

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

The conversion logic for a failed lookup is similar in both GET and HEAD handlers. Consider extracting this functionality into a dedicated helper function to improve maintainability and reduce duplication.

Copilot uses AI. Check for mistakes.
}
}
Expand Down Expand Up @@ -192,17 +200,28 @@ func (m *Manifests) Handle(resp http.ResponseWriter, req *http.Request) error {
}
ma, ok := m.manifests[repo][target]
if !ok {
// First lookup failed, try preparing chart (which might involve its own SemVerReplace)
err := m.prepareChart(req.Context(), repo, target)
if err != nil {
return err
return err // Error from prepareChart
}
// After prepareChart, try lookup again with the potentially modified target from prepareChart
// and then with SemVerReplace if that also fails.
ma, ok = m.manifests[repo][target]
if !ok {
// we failed
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: "Chart prepare error",
originalTarget := target // Store target before SemVerReplace
if m.config.Debug {
m.log.Printf("HEAD: Manifest not found for repo %s with target %s even after prepareChart. Attempting again after converting underscores to plus signs.", repo, originalTarget)
}
target = helper.SemVerReplace(originalTarget) // Convert underscores
ma, ok = m.manifests[repo][target] // Final attempt
if !ok {
// All attempts failed
return &errors.RegError{
Status: http.StatusNotFound,
Code: "NOT FOUND",
Message: fmt.Sprintf("HEAD: Chart manifest not found for repo %s. Tried target '%s' and after underscore conversion '%s'.", repo, originalTarget, target),
}
}
}
}
Expand Down