diff --git a/syft/source/snapsource/snap.go b/syft/source/snapsource/snap.go index 930cee375..c32ed86b6 100644 --- a/syft/source/snapsource/snap.go +++ b/syft/source/snapsource/snap.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "github.com/spf13/afero" @@ -31,21 +32,28 @@ type remoteSnap struct { URL string } +const NotSpecifiedRevision int = 0 + type snapIdentity struct { Name string Channel string Architecture string + Revision int } func (s snapIdentity) String() string { parts := []string{s.Name} + // revision will supersede channel + if s.Revision != NotSpecifiedRevision { + parts = append(parts, fmt.Sprintf(":%d", s.Revision)) + } else { + if s.Channel != "" { + parts = append(parts, fmt.Sprintf("@%s", s.Channel)) + } - if s.Channel != "" { - parts = append(parts, fmt.Sprintf("@%s", s.Channel)) - } - - if s.Architecture != "" { - parts = append(parts, fmt.Sprintf(" (%s)", s.Architecture)) + if s.Architecture != "" { + parts = append(parts, fmt.Sprintf(" (%s)", s.Architecture)) + } } return strings.Join(parts, "") @@ -166,17 +174,21 @@ func getSnapFileInfo(ctx context.Context, fs afero.Fs, path string, hashes []cry // The request can be: // - A snap name (e.g., "etcd") // - A snap name with channel (e.g., "etcd@beta" or "etcd@2.3/stable") +// - A snap name with revision (e.g. etcd:249@stable) func resolveRemoteSnap(request, architecture string) (*remoteSnap, error) { if architecture == "" { architecture = defaultArchitecture } - snapName, channel := parseSnapRequest(request) - + snapName, revision, channel, err := parseSnapRequest(request) + if err != nil { + return nil, err + } id := snapIdentity{ Name: snapName, Channel: channel, Architecture: architecture, + Revision: revision, } client := newSnapcraftClient() @@ -194,15 +206,26 @@ func resolveRemoteSnap(request, architecture string) (*remoteSnap, error) { }, nil } -// parseSnapRequest parses a snap request into name and channel +// parseSnapRequest parses a snap request into name and revision/channel // Examples: // - "etcd" -> name="etcd", channel="stable" (default) // - "etcd@beta" -> name="etcd", channel="beta" // - "etcd@2.3/stable" -> name="etcd", channel="2.3/stable" -func parseSnapRequest(request string) (name, channel string) { +// - "etcd:249@2.3/stable" -> name="etcd" revision=249 (channel not working because revision has been assigned) +func parseSnapRequest(request string) (name string, revision int, channel string, err error) { parts := strings.SplitN(request, "@", 2) name = parts[0] + divisions := strings.Split(parts[0], ":") + // handle revision first + if len(divisions) == 2 { + name = divisions[0] + revision, err = strconv.Atoi(divisions[1]) + if err != nil { + return "", NotSpecifiedRevision, "", err + } + return name, revision, "", err + } if len(parts) == 2 { channel = parts[1] } @@ -210,8 +233,7 @@ func parseSnapRequest(request string) (name, channel string) { if channel == "" { channel = defaultChannel } - - return name, channel + return name, NotSpecifiedRevision, channel, err } func downloadSnap(getter intFile.Getter, info *remoteSnap, dest string) error { diff --git a/syft/source/snapsource/snap_test.go b/syft/source/snapsource/snap_test.go index 24f01a42a..2a0dbbe48 100644 --- a/syft/source/snapsource/snap_test.go +++ b/syft/source/snapsource/snap_test.go @@ -508,78 +508,109 @@ func TestDownloadSnap(t *testing.T) { func TestParseSnapRequest(t *testing.T) { tests := []struct { - name string - request string - expectedName string - expectedChannel string + name string + request string + expectedName string + expectedRevision int + expectedChannel string + expectedError require.ErrorAssertionFunc }{ { name: "snap name only - uses default channel", request: "etcd", expectedName: "etcd", expectedChannel: "stable", + expectedError: require.NoError, }, { name: "snap with beta channel", request: "etcd@beta", expectedName: "etcd", expectedChannel: "beta", + expectedError: require.NoError, }, { name: "snap with edge channel", request: "etcd@edge", expectedName: "etcd", expectedChannel: "edge", + expectedError: require.NoError, }, { name: "snap with version track", request: "etcd@2.3/stable", expectedName: "etcd", expectedChannel: "2.3/stable", + expectedError: require.NoError, }, { name: "snap with complex channel path", request: "mysql@8.0/candidate", expectedName: "mysql", expectedChannel: "8.0/candidate", + expectedError: require.NoError, }, { name: "snap with multiple @ symbols - only first is delimiter", request: "app@beta@test", expectedName: "app", expectedChannel: "beta@test", + expectedError: require.NoError, + }, + { + name: "snap with revision", + request: "etcd:249", + expectedName: "etcd", + expectedRevision: 249, + expectedError: require.NoError, + }, + { + name: "snap with revision so the channel doesn't work", + request: "etcd:249@2.3/beta", + expectedName: "etcd", + expectedRevision: 249, + expectedError: require.NoError, }, { name: "empty snap name with channel", request: "@stable", expectedName: "", expectedChannel: "stable", + expectedError: require.NoError, }, { name: "snap name with empty channel - uses default", request: "etcd@", expectedName: "etcd", expectedChannel: "stable", + expectedError: require.NoError, }, { name: "hyphenated snap name", request: "hello-world@stable", expectedName: "hello-world", expectedChannel: "stable", + expectedError: require.NoError, }, { name: "snap name with numbers", request: "app123", expectedName: "app123", expectedChannel: "stable", + expectedError: require.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - name, channel := parseSnapRequest(tt.request) + name, revision, channel, err := parseSnapRequest(tt.request) assert.Equal(t, tt.expectedName, name) - assert.Equal(t, tt.expectedChannel, channel) + if tt.expectedRevision != NotSpecifiedRevision { + assert.Equal(t, tt.expectedRevision, revision) + } else { + assert.Equal(t, tt.expectedChannel, channel) + } + require.NoError(t, err) }) } } diff --git a/syft/source/snapsource/snapcraft_api.go b/syft/source/snapsource/snapcraft_api.go index 8c00dcbca..0acc82d46 100644 --- a/syft/source/snapsource/snapcraft_api.go +++ b/syft/source/snapsource/snapcraft_api.go @@ -5,6 +5,9 @@ import ( "fmt" "io" "net/http" + "regexp" + "strconv" + "strings" "github.com/anchore/syft/internal/log" ) @@ -58,17 +61,133 @@ type snapFindResponse struct { } `json:"results"` } +type SnapRisk string + +const ( + RiskStable SnapRisk = "stable" + RiskCandidate SnapRisk = "candidate" + RiskBeta SnapRisk = "beta" + RiskEdge SnapRisk = "edge" + RiskUnknown SnapRisk = "unknown" +) + +func isValidSnapRisk(r SnapRisk) bool { + switch r { + case RiskStable, RiskCandidate, RiskBeta, RiskEdge: + return true + default: + return false + } +} + +func stringToSnapRisk(s string) SnapRisk { + r := SnapRisk(s) + if !isValidSnapRisk(r) { + return RiskUnknown + } + return r +} + +func getRevisionFromURL(cm snapChannelMapEntry) (rev int, err error) { + re := regexp.MustCompile(`(\d+)\.snap$`) + match := re.FindStringSubmatch(cm.Download.URL) + if len(match) < 2 { + err = fmt.Errorf("could not determine revision from %s", cm.Download.URL) + return + } + rev, err = strconv.Atoi(match[1]) + return +} + +// isEligibleChannel determines whether a candidate channel satisfies a requested +// channel. Both channels are parsed into {track, risk} pairs. +// +// Matching rules: +// - If the request includes a track, both track and risk must match exactly. +// - If the request omits the track (e.g., "stable"), any candidate track is +// accepted as long as the risk matches. +// +// Examples: +// +// candidate="3.2/stable", request="stable" -> true +// candidate="3.2/stable", request="3.2/stable" -> true +// candidate="3.2/stable", request="3.2/beta" -> false +// candidate="3.2/beta", request="stable" -> false +// candidate="3.2/alpha", request="alpha" -> false(alpha is an invalid risk level) +// candidate="3.2/stable/fix-for-bug123", request="stable" -> true +// candidate="3.2/stable/fix-for-bug123", request="3.2/stable" -> true +func isEligibleChannel(candidate, request string) (bool, error) { + cTrack, cRisk, cBranch := splitChannel(candidate) + rTrack, rRisk, rBranch := splitChannel(request) + if rTrack == "" && rRisk == "" && rBranch == "" { + return false, fmt.Errorf("there is no such risk in the channel(only stable/candidate/beta/edge are valid)") + } + + if rTrack != "" { + return cTrack == rTrack && cRisk == rRisk && (cBranch == rBranch || rBranch == ""), nil + } + + return cRisk == rRisk && (cBranch == rBranch || rBranch == ""), nil +} + +func splitChannel(ch string) (track string, risk string, branch string) { + parts := strings.SplitN(ch, "/", 3) + if stringToSnapRisk(parts[0]) != RiskUnknown { + if len(parts) == 1 { + return "", parts[0], "" // no track + } else if len(parts) == 2 { + return "", parts[0], parts[1] + } + } else if len(parts) >= 2 && stringToSnapRisk(parts[1]) != RiskUnknown { + if len(parts) == 3 { + return parts[0], parts[1], parts[2] + } else if len(parts) == 2 { + return parts[0], parts[1], "" + } + } + + return "", "", "" +} + +func matchSnapDownloadURL(cm snapChannelMapEntry, id snapIdentity) (string, error) { + // revision will supersede channel + if id.Revision != NotSpecifiedRevision { + rev, err2 := getRevisionFromURL(cm) + if err2 == nil && rev == id.Revision { + return cm.Download.URL, nil + } + } else if cm.Channel.Architecture == id.Architecture { + matched, err2 := isEligibleChannel(cm.Channel.Name, id.Channel) + if err2 != nil { + return "", err2 + } + if matched { + return cm.Download.URL, nil + } + } + return "", nil +} + // GetSnapDownloadURL retrieves the download URL for a snap package func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) { apiURL := c.InfoAPIURL + id.Name - log.WithFields("name", id.Name, "channel", id.Channel, "architecture", id.Architecture).Trace("requesting snap info") + if id.Revision == NotSpecifiedRevision { + log.WithFields("name", id.Name, "channel", id.Channel, "architecture", id.Architecture).Trace("requesting snap info") + } else { + log.WithFields("name", id.Name, "revision", id.Revision, "architecture", id.Architecture).Trace("requesting snap info") + } req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return "", fmt.Errorf("failed to create HTTP request: %w", err) } + if id.Revision != NotSpecifiedRevision { + q := req.URL.Query() + q.Add("revision", fmt.Sprintf("%d", id.Revision)) + req.URL.RawQuery = q.Encode() + } req.Header.Set("Snap-Device-Series", defaultSeries) resp, err := c.HTTPClient.Do(req) @@ -107,9 +226,11 @@ func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) { } for _, cm := range info.ChannelMap { - if cm.Channel.Architecture == id.Architecture && cm.Channel.Name == id.Channel { - return cm.Download.URL, nil + url, err2 := matchSnapDownloadURL(cm, id) + if url == "" && err2 == nil { + continue } + return url, err2 } return "", fmt.Errorf("no matching snap found for %s", id.String()) diff --git a/syft/source/snapsource/snapcraft_api_test.go b/syft/source/snapsource/snapcraft_api_test.go index c13516d12..a9473a88c 100644 --- a/syft/source/snapsource/snapcraft_api_test.go +++ b/syft/source/snapsource/snapcraft_api_test.go @@ -161,6 +161,126 @@ func TestSnapcraftClient_GetSnapDownloadURL(t *testing.T) { expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", expectError: require.NoError, }, + { + name: "successful download URL retrieval (w/ track)", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable", + Architecture: "amd64", + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "3.2/stable", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + }, + }, + }, + }, + expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + expectError: require.NoError, + }, + { + name: "successful download URL retrieval (w/ track&branch)", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable", + Architecture: "amd64", + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "3.2/stable/fix-for-bug123", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + }, + }, + }, + }, + expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + expectError: require.NoError, + }, + { + name: "branch unmatched", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable/fix-for-bug124", + Architecture: "amd64", + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "3.2/stable/fix-for-bug123", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + }, + }, + }, + }, + expectError: require.Error, + errorContains: "no matching snap found", + }, + { + name: "risk unmatched", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable", + Architecture: "amd64", + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "latest/beta", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + }, + }, + }, + }, + expectError: require.Error, + errorContains: "no matching snap found", + }, + { + name: "illegal risk", + snapID: snapIdentity{ + Name: "etcd", + Channel: "foobar", + Architecture: "amd64", + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "latest/beta", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap", + }, + }, + }, + }, + expectError: require.Error, + errorContains: "there is no such risk", + }, { name: "region-locked snap - exists but unavailable", snapID: snapIdentity{ @@ -351,6 +471,214 @@ func TestSnapcraftClient_GetSnapDownloadURL(t *testing.T) { } } +func TestSnapcraftClient_GetSnapDownloadURL_WithVersion(t *testing.T) { + tests := []struct { + name string + snapID snapIdentity + infoResponse snapcraftInfo + infoStatusCode int + findResponse *snapFindResponse + findStatusCode int + expectedURL string + expectError require.ErrorAssertionFunc + errorContains string + }{ + { + name: "successful download URL retrieval", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable", + Architecture: "amd64", + Revision: 249, + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "stable", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap", + }, + }, + }, + }, + expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap", + expectError: require.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expectError == nil { + tt.expectError = require.NoError + } + + infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series")) + + expectedPath := "/" + tt.snapID.Name + assert.Equal(t, expectedPath, r.URL.Path) + + w.WriteHeader(tt.infoStatusCode) + + if tt.infoStatusCode == http.StatusOK { + responseBytes, err := json.Marshal(tt.infoResponse) + require.NoError(t, err) + w.Write(responseBytes) + } + })) + defer infoServer.Close() + + var findServer *httptest.Server + if tt.findResponse != nil || tt.findStatusCode != 0 { + findServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series")) + assert.Equal(t, tt.snapID.Name, r.URL.Query().Get("name-startswith")) + + statusCode := tt.findStatusCode + if statusCode == 0 { + statusCode = http.StatusOK + } + w.WriteHeader(statusCode) + + if tt.findResponse != nil && statusCode == http.StatusOK { + responseBytes, err := json.Marshal(tt.findResponse) + require.NoError(t, err) + w.Write(responseBytes) + } + })) + defer findServer.Close() + } + + client := &snapcraftClient{ + InfoAPIURL: infoServer.URL + "/", + HTTPClient: &http.Client{}, + } + if findServer != nil { + client.FindAPIURL = findServer.URL + } + + url, err := client.GetSnapDownloadURL(tt.snapID) + tt.expectError(t, err) + if err != nil { + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + return + } + assert.Equal(t, tt.expectedURL, url) + }) + } +} + +func TestSnapcraftClient_GetSnapDownloadURL_DoesntExist(t *testing.T) { + tests := []struct { + name string + snapID snapIdentity + infoResponse snapcraftInfo + infoStatusCode int + findResponse *snapFindResponse + findStatusCode int + expectedURL string + expectError require.ErrorAssertionFunc + errorContains string + }{ + { + name: "non-existent snap with revision", + snapID: snapIdentity{ + Name: "etcd", + Channel: "stable", + Architecture: "amd64", + Revision: 248, + }, + infoStatusCode: http.StatusOK, + infoResponse: snapcraftInfo{ + ChannelMap: []snapChannelMapEntry{ + { + Channel: snapChannel{ + Architecture: "amd64", + Name: "stable", + }, + Download: snapDownload{ + URL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap", + }, + }, + }, + }, + expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/TKebVGcPeDKoOqAmNmczU2oWLtsojKD5_249.snap", + expectError: func(t require.TestingT, err error, msgAndArgs ...interface{}) { + require.EqualError(t, err, "no matching snap found for etcd:248") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.expectError == nil { + tt.expectError = require.NoError + } + + infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series")) + + expectedPath := "/" + tt.snapID.Name + assert.Equal(t, expectedPath, r.URL.Path) + + w.WriteHeader(tt.infoStatusCode) + + if tt.infoStatusCode == http.StatusOK { + responseBytes, err := json.Marshal(tt.infoResponse) + require.NoError(t, err) + w.Write(responseBytes) + } + })) + defer infoServer.Close() + + var findServer *httptest.Server + if tt.findResponse != nil || tt.findStatusCode != 0 { + findServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series")) + assert.Equal(t, tt.snapID.Name, r.URL.Query().Get("name-startswith")) + + statusCode := tt.findStatusCode + if statusCode == 0 { + statusCode = http.StatusOK + } + w.WriteHeader(statusCode) + + if tt.findResponse != nil && statusCode == http.StatusOK { + responseBytes, err := json.Marshal(tt.findResponse) + require.NoError(t, err) + w.Write(responseBytes) + } + })) + defer findServer.Close() + } + + client := &snapcraftClient{ + InfoAPIURL: infoServer.URL + "/", + HTTPClient: &http.Client{}, + } + if findServer != nil { + client.FindAPIURL = findServer.URL + } + + url, err := client.GetSnapDownloadURL(tt.snapID) + tt.expectError(t, err) + if err != nil { + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + return + } + assert.Equal(t, tt.expectedURL, url) + }) + } +} + func TestSnapcraftClient_GetSnapDownloadURL_InvalidJSON(t *testing.T) { infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK)