github.com/anchore/syft@v1.38.2/syft/source/snapsource/snapcraft_api.go (about)

     1  package snapsource
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  
     9  	"github.com/anchore/syft/internal/log"
    10  )
    11  
    12  const (
    13  	defaultChannel      = "stable"
    14  	defaultArchitecture = "amd64"
    15  	defaultSeries       = "16"
    16  )
    17  
    18  // snapcraftClient handles interactions with the Snapcraft API
    19  type snapcraftClient struct {
    20  	InfoAPIURL string
    21  	FindAPIURL string
    22  	HTTPClient *http.Client
    23  }
    24  
    25  // newSnapcraftClient creates a new Snapcraft API client with default settings
    26  func newSnapcraftClient() *snapcraftClient {
    27  	return &snapcraftClient{
    28  		InfoAPIURL: "https://api.snapcraft.io/v2/snaps/info/",
    29  		FindAPIURL: "https://api.snapcraft.io/v2/snaps/find",
    30  		HTTPClient: &http.Client{},
    31  	}
    32  }
    33  
    34  // snapcraftInfo represents the response from the snapcraft info API
    35  type snapcraftInfo struct {
    36  	ChannelMap []snapChannelMapEntry `json:"channel-map"`
    37  }
    38  
    39  type snapChannelMapEntry struct {
    40  	Channel  snapChannel  `json:"channel"`
    41  	Download snapDownload `json:"download"`
    42  }
    43  type snapChannel struct {
    44  	Architecture string `json:"architecture"`
    45  	Name         string `json:"name"`
    46  }
    47  
    48  type snapDownload struct {
    49  	URL string `json:"url"`
    50  }
    51  
    52  // snapFindResponse represents the response from the snapcraft find API (search v2)
    53  type snapFindResponse struct {
    54  	Results []struct {
    55  		Name   string   `json:"name"`
    56  		SnapID string   `json:"snap-id"`
    57  		Snap   struct{} `json:"snap"`
    58  	} `json:"results"`
    59  }
    60  
    61  // GetSnapDownloadURL retrieves the download URL for a snap package
    62  func (c *snapcraftClient) GetSnapDownloadURL(id snapIdentity) (string, error) {
    63  	apiURL := c.InfoAPIURL + id.Name
    64  
    65  	log.WithFields("name", id.Name, "channel", id.Channel, "architecture", id.Architecture).Trace("requesting snap info")
    66  
    67  	req, err := http.NewRequest(http.MethodGet, apiURL, nil)
    68  	if err != nil {
    69  		return "", fmt.Errorf("failed to create HTTP request: %w", err)
    70  	}
    71  
    72  	req.Header.Set("Snap-Device-Series", defaultSeries)
    73  
    74  	resp, err := c.HTTPClient.Do(req)
    75  	if err != nil {
    76  		return "", fmt.Errorf("failed to send HTTP request: %w", err)
    77  	}
    78  	defer resp.Body.Close()
    79  
    80  	// handle 404 case - check if snap exists via find API
    81  	if resp.StatusCode == http.StatusNotFound {
    82  		log.WithFields("name", id.Name).Debug("snap info not found, checking if snap exists via find API")
    83  
    84  		exists, snapID, findErr := c.CheckSnapExists(id.Name)
    85  		if findErr != nil {
    86  			return "", fmt.Errorf("failed to check if snap exists: %w", findErr)
    87  		}
    88  
    89  		if exists {
    90  			return "", fmt.Errorf("found snap '%s' (id=%s) but it is unavailable for download", id.Name, snapID)
    91  		}
    92  		return "", fmt.Errorf("no snap found with name '%s'", id.Name)
    93  	}
    94  
    95  	if resp.StatusCode != http.StatusOK {
    96  		return "", fmt.Errorf("API request failed with status code %d", resp.StatusCode)
    97  	}
    98  
    99  	body, err := io.ReadAll(resp.Body)
   100  	if err != nil {
   101  		return "", fmt.Errorf("failed to read response body: %w", err)
   102  	}
   103  
   104  	var info snapcraftInfo
   105  	if err := json.Unmarshal(body, &info); err != nil {
   106  		return "", fmt.Errorf("failed to parse JSON response: %w", err)
   107  	}
   108  
   109  	for _, cm := range info.ChannelMap {
   110  		if cm.Channel.Architecture == id.Architecture && cm.Channel.Name == id.Channel {
   111  			return cm.Download.URL, nil
   112  		}
   113  	}
   114  
   115  	return "", fmt.Errorf("no matching snap found for %s", id.String())
   116  }
   117  
   118  // CheckSnapExists uses the find API (search v2) to check if a snap exists
   119  func (c *snapcraftClient) CheckSnapExists(snapName string) (bool, string, error) {
   120  	req, err := http.NewRequest(http.MethodGet, c.FindAPIURL, nil)
   121  	if err != nil {
   122  		return false, "", fmt.Errorf("failed to create find request: %w", err)
   123  	}
   124  
   125  	q := req.URL.Query()
   126  	q.Add("name-startswith", snapName)
   127  	req.URL.RawQuery = q.Encode()
   128  
   129  	req.Header.Set("Snap-Device-Series", defaultSeries)
   130  
   131  	resp, err := c.HTTPClient.Do(req)
   132  	if err != nil {
   133  		return false, "", fmt.Errorf("failed to send find request: %w", err)
   134  	}
   135  	defer resp.Body.Close()
   136  
   137  	if resp.StatusCode != http.StatusOK {
   138  		return false, "", fmt.Errorf("find API request failed with status code %d", resp.StatusCode)
   139  	}
   140  
   141  	body, err := io.ReadAll(resp.Body)
   142  	if err != nil {
   143  		return false, "", fmt.Errorf("failed to read find response body: %w", err)
   144  	}
   145  
   146  	var findResp snapFindResponse
   147  	if err := json.Unmarshal(body, &findResp); err != nil {
   148  		return false, "", fmt.Errorf("failed to parse find JSON response: %w", err)
   149  	}
   150  
   151  	// Look for exact name match
   152  	for _, result := range findResp.Results {
   153  		if result.Name == snapName {
   154  			return true, result.SnapID, nil
   155  		}
   156  	}
   157  
   158  	return false, "", nil
   159  }