Skip to content
Merged
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
46 changes: 40 additions & 6 deletions platforms.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ package platforms

import (
"fmt"
"net/url"
"path"
"regexp"
"runtime"
Expand All @@ -123,7 +124,7 @@ import (

var (
specifierRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
osRe = regexp.MustCompile(`^([A-Za-z0-9_-]+)(?:\(([A-Za-z0-9_.-]*)((?:\+[A-Za-z0-9_.-]+)*)\))?$`)
osRe = regexp.MustCompile(`^([A-Za-z0-9_-]+)(?:\(([A-Za-z0-9_.%-]*)((?:\+[A-Za-z0-9_.%-]+)*)\))?$`)
)

// Platform is a type alias for convenience, so there is no need to import image-spec package everywhere.
Expand Down Expand Up @@ -240,9 +241,20 @@ func Parse(specifier string) (specs.Platform, error) {
}

p.OS = normalizeOS(osOptions[1])
p.OSVersion = osOptions[2]
osVersion, err := decodeOSOption(osOptions[2])
if err != nil {
return specs.Platform{}, fmt.Errorf("%q has an invalid OS version %q: %w", specifier, osOptions[2], err)
}
p.OSVersion = osVersion
if osOptions[3] != "" {
p.OSFeatures = strings.Split(osOptions[3][1:], "+")
rawFeatures := strings.Split(osOptions[3][1:], "+")
p.OSFeatures = make([]string, len(rawFeatures))
for i, f := range rawFeatures {
p.OSFeatures[i], err = decodeOSOption(f)
if err != nil {
return specs.Platform{}, fmt.Errorf("%q has an invalid OS feature %q: %w", specifier, f, err)
}
}
}
} else {
if !specifierRe.MatchString(part) {
Expand Down Expand Up @@ -327,14 +339,14 @@ func FormatAll(platform specs.Platform) string {
return "unknown"
}

osOptions := platform.OSVersion
osOptions := encodeOSOption(platform.OSVersion)
features := platform.OSFeatures
if !slices.IsSorted(features) {
features = slices.Clone(features)
slices.Sort(features)
}
if len(features) > 0 {
osOptions += "+" + strings.Join(features, "+")
for _, f := range features {
osOptions += "+" + encodeOSOption(f)
}
if osOptions != "" {
OSAndVersion := fmt.Sprintf("%s(%s)", platform.OS, osOptions)
Expand All @@ -343,6 +355,28 @@ func FormatAll(platform specs.Platform) string {
return path.Join(platform.OS, platform.Architecture, platform.Variant)
}

// osOptionReplacer encodes characters in OS option values (version and
// features) that are ambiguous with the format syntax. The percent sign
// must be replaced first to avoid double-encoding.
var osOptionReplacer = strings.NewReplacer(
"%", "%25",
"+", "%2B",
"(", "%28",
")", "%29",
"/", "%2F",
)

func encodeOSOption(v string) string {
return osOptionReplacer.Replace(v)
}

func decodeOSOption(v string) (string, error) {
if strings.Contains(v, "%") {
return url.PathUnescape(v)
}
return v, nil
}

// Normalize validates and translate the platform to the canonical value.
//
// For example, if "Aarch64" is encountered, we change it to "arm64" or if
Expand Down
101 changes: 101 additions & 0 deletions platforms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,40 @@ func TestParseSelector(t *testing.T) {
formatted: path.Join("linux(+erofs+unsorted)", defaultArch, defaultVariant),
useV2Format: true,
},
{
input: "windows(10.0.17763%2Bbuild.42)",
expected: specs.Platform{
OS: "windows",
OSVersion: "10.0.17763+build.42",
Architecture: defaultArch,
Variant: defaultVariant,
},
formatted: path.Join("windows(10.0.17763%2Bbuild.42)", defaultArch, defaultVariant),
useV2Format: true,
},
{
input: "windows(10.0.17763%2Bbuild.42+win32k)",
expected: specs.Platform{
OS: "windows",
OSVersion: "10.0.17763+build.42",
OSFeatures: []string{"win32k"},
Architecture: defaultArch,
Variant: defaultVariant,
},
formatted: path.Join("windows(10.0.17763%2Bbuild.42+win32k)", defaultArch, defaultVariant),
useV2Format: true,
},
{
input: "windows(50%25done)",
expected: specs.Platform{
OS: "windows",
OSVersion: "50%done",
Architecture: defaultArch,
Variant: defaultVariant,
},
formatted: path.Join("windows(50%25done)", defaultArch, defaultVariant),
useV2Format: true,
},
} {
t.Run(testcase.input, func(t *testing.T) {
if testcase.skip {
Expand Down Expand Up @@ -446,6 +480,73 @@ func TestParseSelector(t *testing.T) {
}
}

func TestFormatAllEncoding(t *testing.T) {
for _, testcase := range []struct {
platform specs.Platform
expected string
}{
{
platform: specs.Platform{OS: "windows", OSVersion: "10.0.17763+build.42", Architecture: "amd64"},
expected: "windows(10.0.17763%2Bbuild.42)/amd64",
},
{
platform: specs.Platform{OS: "windows", OSVersion: "10.0.17763+build.42", OSFeatures: []string{"win32k"}, Architecture: "amd64"},
expected: "windows(10.0.17763%2Bbuild.42+win32k)/amd64",
},
{
platform: specs.Platform{OS: "windows", OSVersion: "50%done", Architecture: "amd64"},
expected: "windows(50%25done)/amd64",
},
{
platform: specs.Platform{OS: "windows", OSVersion: "1.0(beta)", Architecture: "amd64"},
expected: "windows(1.0%28beta%29)/amd64",
},
{
platform: specs.Platform{OS: "windows", OSVersion: "a/b", Architecture: "amd64"},
expected: "windows(a%2Fb)/amd64",
},
{
// no special characters, no encoding needed
platform: specs.Platform{OS: "windows", OSVersion: "10.0.17763", Architecture: "amd64"},
expected: "windows(10.0.17763)/amd64",
},
{
// feature with + in the name
platform: specs.Platform{OS: "linux", OSFeatures: []string{"feat+v2"}, Architecture: "amd64"},
expected: "linux(+feat%2Bv2)/amd64",
},
{
// feature with % in the name
platform: specs.Platform{OS: "linux", OSFeatures: []string{"100%gpu"}, Architecture: "amd64"},
expected: "linux(+100%25gpu)/amd64",
},
{
// version and feature both with special characters
platform: specs.Platform{OS: "windows", OSVersion: "10.0+build", OSFeatures: []string{"feat+1"}, Architecture: "amd64"},
expected: "windows(10.0%2Bbuild+feat%2B1)/amd64",
},
} {
t.Run(testcase.expected, func(t *testing.T) {
formatted := FormatAll(testcase.platform)
if formatted != testcase.expected {
t.Fatalf("unexpected format: %q != %q", formatted, testcase.expected)
}

// verify round-trip
reparsed, err := Parse(formatted)
if err != nil {
t.Fatalf("error parsing formatted output: %v", err)
}
if reparsed.OSVersion != testcase.platform.OSVersion {
t.Fatalf("OSVersion did not survive round trip: %q != %q", reparsed.OSVersion, testcase.platform.OSVersion)
}
if !reflect.DeepEqual(reparsed.OSFeatures, testcase.platform.OSFeatures) {
t.Fatalf("OSFeatures did not survive round trip: %v != %v", reparsed.OSFeatures, testcase.platform.OSFeatures)
}
})
}
}

func TestParseSelectorInvalid(t *testing.T) {
for _, testcase := range []struct {
input string
Expand Down
Loading