package snapsource import ( "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "github.com/anchore/syft/internal/log" ) const ( defaultChannel = "stable" defaultArchitecture = "amd64" defaultSeries = "16" ) // snapcraftClient handles interactions with the Snapcraft API type snapcraftClient struct { InfoAPIURL string FindAPIURL string HTTPClient *http.Client } // newSnapcraftClient creates a new Snapcraft API client with default settings func newSnapcraftClient() *snapcraftClient { return &snapcraftClient{ InfoAPIURL: "https://api.snapcraft.io/v2/snaps/info/", FindAPIURL: "https://api.snapcraft.io/v2/snaps/find", HTTPClient: &http.Client{}, } } // snapcraftInfo represents the response from the snapcraft info API type snapcraftInfo struct { ChannelMap []snapChannelMapEntry `json:"channel-map"` } type snapChannelMapEntry struct { Channel snapChannel `json:"channel"` Download snapDownload `json:"download"` } type snapChannel struct { Architecture string `json:"architecture"` Name string `json:"name"` } type snapDownload struct { URL string `json:"url"` } // snapFindResponse represents the response from the snapcraft find API (search v2) type snapFindResponse struct { Results []struct { Name string `json:"name"` SnapID string `json:"snap-id"` Snap struct{} `json:"snap"` } `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 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) if err != nil { return "", fmt.Errorf("failed to send HTTP request: %w", err) } defer resp.Body.Close() // handle 404 case - check if snap exists via find API if resp.StatusCode == http.StatusNotFound { log.WithFields("name", id.Name).Debug("snap info not found, checking if snap exists via find API") exists, snapID, findErr := c.CheckSnapExists(id.Name) if findErr != nil { return "", fmt.Errorf("failed to check if snap exists: %w", findErr) } if exists { return "", fmt.Errorf("found snap '%s' (id=%s) but it is unavailable for download", id.Name, snapID) } return "", fmt.Errorf("no snap found with name '%s'", id.Name) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API request failed with status code %d", resp.StatusCode) } var info snapcraftInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return "", fmt.Errorf("failed to parse JSON response: %w", err) } for _, cm := range info.ChannelMap { url, err2 := matchSnapDownloadURL(cm, id) if url == "" && err2 == nil { continue } return url, err2 } return "", fmt.Errorf("no matching snap found for %s", id.String()) } // CheckSnapExists uses the find API (search v2) to check if a snap exists func (c *snapcraftClient) CheckSnapExists(snapName string) (bool, string, error) { req, err := http.NewRequest(http.MethodGet, c.FindAPIURL, nil) if err != nil { return false, "", fmt.Errorf("failed to create find request: %w", err) } q := req.URL.Query() q.Add("name-startswith", snapName) req.URL.RawQuery = q.Encode() req.Header.Set("Snap-Device-Series", defaultSeries) resp, err := c.HTTPClient.Do(req) if err != nil { return false, "", fmt.Errorf("failed to send find request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, "", fmt.Errorf("find API request failed with status code %d", resp.StatusCode) } var findResp snapFindResponse if err := json.NewDecoder(resp.Body).Decode(&findResp); err != nil { return false, "", fmt.Errorf("failed to parse find JSON response: %w", err) } // Look for exact name match for _, result := range findResp.Results { if result.Name == snapName { return true, result.SnapID, nil } } return false, "", nil }