Skip to content

Commit eda584f

Browse files
Fix: Handle OCI artifact versions with '+' correctly
This change addresses an issue where Helm's automatic replacement of '+' with '_' in version strings causes problems with OCI artifact registries that do not support '+' in tags. The following improvements have been made: - Modified `internal/helper/helper.go`: - Added comprehensive documentation to the `SemVerReplace` function. - Clarified that `SemVerReplace` focuses on underscore-to-plus conversion and does not perform full semver validation. - Simplified the `SemVerReplace` function logic. - Added extensive unit tests for `SemVerReplace` covering various scenarios, including empty strings, multiple underscores, leading/trailing underscores, and mixed-case strings. - Updated `internal/manifest/charts.go`: - Added debug logging before attempting underscore-to-plus conversion for chart versions. - Enhanced error messages to be more specific when a chart version is not found, indicating that lookups were attempted with both the original reference and the converted reference. - Updated `internal/manifest/manifest.go`: - Ensured consistent handling of underscore-to-plus conversion for manifest targets in both GET and HEAD request methods. - Added debug logging before attempting underscore-to-plus conversion for manifest targets in both GET and HEAD methods. - Enhanced error messages to be more specific when a manifest is not found, indicating that lookups were attempted with both the original target and the converted target. These changes ensure that versions containing '+' are correctly processed by first attempting the lookup with the original reference (which might contain '_') and then, if that fails, by attempting the lookup again after converting underscores to plus signs. This provides more robust handling for OCI artifacts and improves debuggability.
1 parent 1b55251 commit eda584f

File tree

4 files changed

+175
-15
lines changed

4 files changed

+175
-15
lines changed

internal/helper/helper.go

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import (
2020
)
2121

2222
// Returns whether this url should be handled by the Blob handler
23-
// This is complicated because Blob is indicated by the trailing path, not the leading path.
24-
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulling-a-layer
25-
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-a-layer
23+
// This is complicated because Blob is indicated by the trailing path, not the l
24+
eading path.
25+
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulli
26+
ng-a-layer
27+
// https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushi
28+
ng-a-layer
2629
func IsBlob(req *http.Request) bool {
2730
elem := strings.Split(req.URL.Path, "/")
2831
elem = elem[1:]
@@ -71,3 +74,58 @@ func IsV2(req *http.Request) bool {
7174
}
7275
return elems[len(elems)-1] == "v2"
7376
}
77+
78+
// SemVerReplace replaces all underscores in a given string with plus signs.
79+
// This function is primarily intended for use with semantic version strings
80+
// where underscores might have been used in place of plus signs (e.g., for
81+
// build metadata or pre-release identifiers). Standard Semantic Versioning
82+
// (semver.org) specifies plus signs for build metadata (e.g., "1.0.0+20130313144700")
83+
// and hyphens for pre-release identifiers (e.g., "1.0.0-alpha").
84+
//
85+
// Purpose:
86+
// The main purpose of this function is to normalize version strings by converting
87+
// any underscores to plus signs. This can be particularly useful when dealing with
88+
// version strings from systems or sources that use underscores due to constraints
89+
// (e.g., where '+' is a special character) or by convention for information that
90+
// semantically aligns with build metadata.
91+
//
92+
// When to use it:
93+
// Use this function when you encounter version strings like "v1.2.3_build456" or
94+
// "2.0.0_rc_1" and need to transform them into a format like "v1.2.3+build456" or
95+
// "2.0.0+rc+1". This transformation is often a preparatory step before parsing
96+
// the string with a semantic versioning library that strictly expects '+' for
97+
// build metadata, or when aiming for a consistent display format for version information.
98+
//
99+
// Transformation Examples:
100+
// - Input: "1.0.0_alpha"
101+
// Output: "1.0.0+alpha"
102+
// - Input: "v2.1.3_beta_build123" (handles multiple underscores)
103+
// Output: "v2.1.3+beta+build123"
104+
// - Input: "1.2.3" (string with no underscores)
105+
// Output: "1.2.3" (string remains unchanged)
106+
// - Input: "" (empty string)
107+
// Output: "" (empty string remains unchanged)
108+
//
109+
// Semver Validation:
110+
// This function does NOT perform validation of the overall semantic version string structure.
111+
// For example, it does not check if the version string conforms to the MAJOR.MINOR.PATCH
112+
// numerical format or other specific semver rules. Its sole responsibility is to
113+
// replace every occurrence of the underscore character '_' with a plus sign '+'.
114+
// For comprehensive semver parsing and validation, it is recommended to use a
115+
// dedicated semver library on the string after this transformation, if necessary.
116+
//
117+
// Edge Cases Handled:
118+
// - Multiple underscores: All occurrences of underscores are replaced.
119+
// For instance, "1.0.0_alpha_snapshot" becomes "1.0.0+alpha+snapshot".
120+
// - Empty string: If an empty string is provided, an empty string is returned.
121+
// - String without underscores: If the string does not contain any underscores,
122+
// it is returned as is.
123+
func SemVerReplace(semver string) string {
124+
// strings.ReplaceAll is efficient and handles edge cases gracefully:
125+
// - If `semver` is an empty string, it returns an empty string.
126+
// - If `semver` does not contain "_", it returns `semver` unchanged.
127+
// - It replaces all occurrences of "_" with "+".
128+
// Therefore, the original conditional check (if semver != "" && strings.Contains(semver, "_"))
129+
// is not strictly necessary for correctness.
130+
return strings.ReplaceAll(semver, "_", "+")
131+
}

internal/helper/helper_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package helper
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestSemVerReplace(t *testing.T) {
8+
testCases := []struct {
9+
name string
10+
input string
11+
expected string
12+
}{
13+
{
14+
name: "empty string",
15+
input: "",
16+
expected: "",
17+
},
18+
{
19+
name: "no underscores",
20+
input: "1.0.0",
21+
expected: "1.0.0",
22+
},
23+
{
24+
name: "one underscore",
25+
input: "1.0.0_alpha",
26+
expected: "1.0.0+alpha",
27+
},
28+
{
29+
name: "multiple underscores",
30+
input: "1.0.0_alpha_build123",
31+
expected: "1.0.0+alpha+build123",
32+
},
33+
{
34+
name: "leading and trailing underscores",
35+
input: "_1.0.0_",
36+
expected: "+1.0.0+",
37+
},
38+
{
39+
name: "only underscores",
40+
input: "___",
41+
expected: "+++",
42+
},
43+
{
44+
name: "mixed case and underscores",
45+
input: "v1.2.3_Rc1_candidate",
46+
expected: "v1.2.3+Rc1+candidate",
47+
},
48+
{
49+
name: "no underscores with v prefix",
50+
input: "v2.3.4",
51+
expected: "v2.3.4",
52+
},
53+
{
54+
name: "already has plus (should not change)",
55+
input: "1.0.0+beta",
56+
expected: "1.0.0+beta",
57+
},
58+
{
59+
name: "mixed plus and underscore",
60+
input: "1.0.0_alpha+beta_rc1",
61+
expected: "1.0.0+alpha+beta+rc1",
62+
},
63+
}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
actual := SemVerReplace(tc.input)
68+
if actual != tc.expected {
69+
t.Errorf("SemVerReplace(%q) = %q; want %q", tc.input, actual, tc.expected)
70+
}
71+
})
72+
}
73+
}

internal/manifest/charts.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler"
88
"github.com/container-registry/helm-charts-oci-proxy/internal/errors"
9+
"github.com/container-registry/helm-charts-oci-proxy/internal/helper"
910
"github.com/opencontainers/go-digest"
1011
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1112
"helm.sh/helm/v3/pkg/chart"
@@ -47,10 +48,19 @@ func (m *Manifests) prepareChart(ctx context.Context, repo string, reference str
4748
m.log.Printf("searching index for %s with reference %s\n", chart, reference)
4849
chartVer, err := index.Get(chart, reference)
4950
if err != nil {
51+
originalReference := reference // Store Original Reference
52+
if m.config.Debug {
53+
m.log.Printf("Chart lookup for '%s' with reference '%s' failed. Attempting again after converting underscores to plus signs in reference.", chart, originalReference)
54+
}
55+
reference = helper.SemVerReplace(originalReference) // Use originalReference for conversion
56+
chartVer, err = index.Get(chart, reference)
57+
}
58+
if err != nil {
59+
5060
return &errors.RegError{
5161
Status: http.StatusNotFound,
5262
Code: "NOT FOUND",
53-
Message: fmt.Sprintf("Chart: %s version: %s not found: %v", chart, reference, err),
63+
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),
5464
}
5565
}
5666

internal/manifest/manifest.go

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,19 @@ func (m *Manifests) Handle(resp http.ResponseWriter, req *http.Request) error {
160160

161161
ma, ok = c[target]
162162
if !ok {
163-
// we failed
164-
return &errors.RegError{
165-
Status: http.StatusNotFound,
166-
Code: "NOT FOUND",
167-
Message: fmt.Sprintf("Chart prepare's result not found: %v, %v", repo, target),
163+
originalTarget := target // Store original target
164+
if m.config.Debug {
165+
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)
166+
}
167+
target = helper.SemVerReplace(originalTarget) // Use originalTarget for conversion
168+
ma, ok = c[target] // Attempt lookup with the new target
169+
if !ok {
170+
// we failed again
171+
return &errors.RegError{
172+
Status: http.StatusNotFound,
173+
Code: "NOT FOUND",
174+
Message: fmt.Sprintf("GET: Chart prepare's result not found for repo %s. Tried target '%s' and after underscore conversion '%s'.", repo, originalTarget, target),
175+
}
168176
}
169177
}
170178
}
@@ -192,17 +200,28 @@ func (m *Manifests) Handle(resp http.ResponseWriter, req *http.Request) error {
192200
}
193201
ma, ok := m.manifests[repo][target]
194202
if !ok {
203+
// First lookup failed, try preparing chart (which might involve its own SemVerReplace)
195204
err := m.prepareChart(req.Context(), repo, target)
196205
if err != nil {
197-
return err
206+
return err // Error from prepareChart
198207
}
208+
// After prepareChart, try lookup again with the potentially modified target from prepareChart
209+
// and then with SemVerReplace if that also fails.
199210
ma, ok = m.manifests[repo][target]
200211
if !ok {
201-
// we failed
202-
return &errors.RegError{
203-
Status: http.StatusNotFound,
204-
Code: "NOT FOUND",
205-
Message: "Chart prepare error",
212+
originalTarget := target // Store target before SemVerReplace
213+
if m.config.Debug {
214+
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)
215+
}
216+
target = helper.SemVerReplace(originalTarget) // Convert underscores
217+
ma, ok = m.manifests[repo][target] // Final attempt
218+
if !ok {
219+
// All attempts failed
220+
return &errors.RegError{
221+
Status: http.StatusNotFound,
222+
Code: "NOT FOUND",
223+
Message: fmt.Sprintf("HEAD: Chart manifest not found for repo %s. Tried target '%s' and after underscore conversion '%s'.", repo, originalTarget, target),
224+
}
206225
}
207226
}
208227
}

0 commit comments

Comments
 (0)