From c65854048c7dc883f911ef2075ad4a45d7bc180c Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 21 Oct 2026 15:29:26 +0300 Subject: [PATCH] Support remote and virtual repos in jf xr status Resolve the given repo to the concrete repo Xray tracks before querying it: a remote repo maps to its cache repo and a virtual repo to the member repo that holds the artifact. Multi-arch docker images are expanded into their platform manifests, so a blocked platform marks the image as blocked. Co-Authored-By: Claude Sonnet 5.5 --- cli/docs/xray/downloadstatus/help.go | 2 + .../xray/downloadstatus/downloadstatus.go | 128 +++++++++------ .../downloadstatus/downloadstatus_test.go | 4 +- commands/xray/downloadstatus/index.go | 138 ++++++++++++++++ commands/xray/downloadstatus/index_test.go | 121 ++++++++++++++ commands/xray/downloadstatus/resolve.go | 102 ++++++++++++ commands/xray/downloadstatus/resolve_test.go | 149 ++++++++++++++++++ 7 files changed, 593 insertions(+), 51 deletions(-) create mode 100644 commands/xray/downloadstatus/index.go create mode 100644 commands/xray/downloadstatus/index_test.go create mode 100644 commands/xray/downloadstatus/resolve.go create mode 100644 commands/xray/downloadstatus/resolve_test.go diff --git a/cli/docs/xray/downloadstatus/help.go b/cli/docs/xray/downloadstatus/help.go index 495952b8c..8f2759b16 100644 --- a/cli/docs/xray/downloadstatus/help.go +++ b/cli/docs/xray/downloadstatus/help.go @@ -23,6 +23,8 @@ Notes: - UNKNOWN means the violation scan has not finished, failed, or is partial, or the indexed checksum does not match the current file. A watch that blocks unscanned artifacts can still block the download while the status is UNKNOWN. - A blocking violation always reports BLOCKED, even if the scan itself failed or is partial. - A scan status of "not supported" reports ALLOWED, even though a watch that blocks unscanned artifacts could still block the real download. +- The repository can be local, remote or virtual. A remote repo is resolved to its cache repo, and a virtual repo to the member repo that serves the artifact. The resolved repo is reported next to the one you passed. +- For a multi-arch docker image, each platform manifest present in the repository is checked and listed. If any of them is blocked, the image is reported as BLOCKED, and the violations show which platform they belong to. - A docker pull reference ([host/]/:, or @sha256:) is resolved to the manifest Artifactory stores for that image. Common patterns: diff --git a/commands/xray/downloadstatus/downloadstatus.go b/commands/xray/downloadstatus/downloadstatus.go index 10e54c273..e73bfd4e5 100644 --- a/commands/xray/downloadstatus/downloadstatus.go +++ b/commands/xray/downloadstatus/downloadstatus.go @@ -233,35 +233,76 @@ func (cmd *DownloadStatusCommand) Run() (err error) { } func (cmd *DownloadStatusCommand) FetchResult() (*Result, error) { - path, sha256, err := cmd.resolveArtifact() + artifactoryManager, err := artifactoryUtils.CreateServiceManager(cmd.serverDetails, -1, 0, false) + if err != nil { + return nil, err + } + located, err := locateArtifact(artifactoryManager, cmd.repo, cmd.pathCandidates) if err != nil { return nil, err } - log.Debug(fmt.Sprintf("Resolved artifact %s/%s sha256 %s", cmd.repo, path, sha256)) + repo, path := located.repo, located.path + log.Debug(fmt.Sprintf("Resolved artifact %s/%s sha256 %s", repo, path, located.sha256)) xrayManager, err := corexray.CreateXrayServiceManager(cmd.serverDetails, corexray.WithScopedProjectKey(cmd.project)) if err != nil { return nil, err } + result, err := cmd.evaluateArtifact(xrayManager, repo, path, located.sha256) + if err != nil { + return nil, err + } + + if isManifestListPath(path) { + platforms, listErr := cmd.evaluatePlatformManifests(artifactoryManager, xrayManager, repo, path) + if listErr != nil { + log.Debug(fmt.Sprintf("Could not expand manifest list %s/%s: %s", repo, path, listErr.Error())) + } + mergePlatformResults(result, platforms) + } + + if repo != cmd.repo { + result.RequestedRepo = cmd.repo + } + log.Debug(fmt.Sprintf("Download status for %s/%s is %s (%s)", repo, path, result.DownloadStatus, result.StatusReason)) + return result, nil +} + +func (cmd *DownloadStatusCommand) evaluatePlatformManifests(source manifestSource, xrayManager *xray.XrayServicesManager, repo, listPath string) ([]platformResult, error) { + manifests, err := listPlatformManifests(source, repo, listPath) + if err != nil { + return nil, err + } + var platforms []platformResult + for _, manifest := range manifests { + platformResultValue, err := cmd.evaluateArtifact(xrayManager, repo, manifest.path, manifest.sha256) + if err != nil { + return platforms, err + } + platforms = append(platforms, platformResult{platform: manifest.platform, result: platformResultValue}) + } + return platforms, nil +} - scanStatus, err := xrayManager.GetArtifactStatus(cmd.repo, path) +func (cmd *DownloadStatusCommand) evaluateArtifact(xrayManager *xray.XrayServicesManager, repo, path, sha256 string) (*Result, error) { + scanStatus, err := xrayManager.GetArtifactStatus(repo, path) if err != nil { return nil, err } if scanStatus != nil { - log.Debug(fmt.Sprintf("Violation scan status for %s/%s is %s", cmd.repo, path, scanStatus.Details.Violations.Status)) + log.Debug(fmt.Sprintf("Violation scan status for %s/%s is %s", repo, path, scanStatus.Details.Violations.Status)) } violationsResponse, err := xrayManager.GetViolations( xrayUtils.NewViolationsRequest(). IncludeDetails(true). - FilterByArtifacts(xrayUtils.ArtifactResourceFilter{Repository: cmd.repo, Path: path}), + FilterByArtifacts(xrayUtils.ArtifactResourceFilter{Repository: repo, Path: path}), ) if err != nil { return nil, err } - packageId, version, indexedSha256 := cmd.resolvePackageIdentity(xrayManager, path) + packageId, version, indexedSha256 := cmd.resolvePackageIdentity(xrayManager, repo, path) var violations []services.XrayViolation if violationsResponse != nil { violations = violationsResponse.Violations @@ -271,17 +312,15 @@ func (cmd *DownloadStatusCommand) FetchResult() (*Result, error) { log.Debug(fmt.Sprintf("Indexed sha256 %s does not match artifact sha256 %s", indexedSha256, sha256)) violations = nil } - result := buildResult(cmd.repo, path, sha256, cmd.serverDetails.Url, packageId, version, checksumMismatch, scanStatus, violations) - log.Debug(fmt.Sprintf("Download status for %s/%s is %s (%s)", cmd.repo, path, result.DownloadStatus, result.StatusReason)) - return result, nil + return buildResult(repo, path, sha256, cmd.serverDetails.Url, packageId, version, checksumMismatch, scanStatus, violations), nil } // resolvePackageIdentity looks up how Xray identifies this artifact as a package (e.g. 'deb://debian:12:libxml2'), // which the scans-list UI needs in its URL. Falls back to a generic identity if the artifact isn't a recognized // package type or the lookup fails - the resulting link still works, just without the platform's stricter package // context. -func (cmd *DownloadStatusCommand) resolvePackageIdentity(xrayManager *xray.XrayServicesManager, path string) (packageId, version, indexedSha256 string) { - candidates := cmd.artifactSummaryPaths(path) +func (cmd *DownloadStatusCommand) resolvePackageIdentity(xrayManager *xray.XrayServicesManager, repo, path string) (packageId, version, indexedSha256 string) { + candidates := cmd.artifactSummaryPaths(repo, path) log.Debug(fmt.Sprintf("Looking up package identity for %s/%s via %s", cmd.repo, path, strings.Join(candidates, ", "))) for _, candidate := range candidates { summary, err := xrayManager.ArtifactSummary(services.ArtifactSummaryParams{Paths: []string{candidate}, CliCommand: "xr_status"}) @@ -302,8 +341,8 @@ func (cmd *DownloadStatusCommand) resolvePackageIdentity(xrayManager *xray.XrayS return "generic://" + base, "", "" } -func (cmd *DownloadStatusCommand) artifactSummaryPaths(path string) []string { - repoPath := cmd.repo + "/" + path +func (cmd *DownloadStatusCommand) artifactSummaryPaths(repo, path string) []string { + repoPath := repo + "/" + path if cmd.project != "" { // Project-scoped first: an unscoped or default/ hit can belong to a // different project and carry a checksum that doesn't match this artifact. @@ -357,32 +396,6 @@ func xrayPackageScheme(pkgType string) string { return lower } -// resolveArtifact tries each path candidate in turn (a docker tag can resolve to a manifest list or a plain -// manifest) and returns the first one Artifactory actually has, along with its sha256. -func (cmd *DownloadStatusCommand) resolveArtifact() (path, sha256 string, err error) { - artifactoryManager, err := artifactoryUtils.CreateServiceManager(cmd.serverDetails, -1, 0, false) - if err != nil { - return "", "", err - } - var lastErr error - for _, candidate := range cmd.pathCandidates { - fileInfo, ferr := artifactoryManager.FileInfo(cmd.repo + "/" + candidate) - if ferr == nil { - return candidate, fileInfo.Checksums.Sha256, nil - } - if !isArtifactNotFound(ferr) { - log.Debug(fmt.Sprintf("File info for %s/%s failed: %s", cmd.repo, candidate, ferr.Error())) - return "", "", ferr - } - log.Debug(fmt.Sprintf("Artifact %s/%s was not found", cmd.repo, candidate)) - lastErr = ferr - } - if lastErr == nil { - return "", "", errorutils.CheckErrorf("could not find artifact under repo '%s' (tried: %s)", cmd.repo, strings.Join(cmd.pathCandidates, ", ")) - } - return "", "", errorutils.CheckErrorf("could not find artifact under repo '%s' (tried: %s): %s", cmd.repo, strings.Join(cmd.pathCandidates, ", "), lastErr.Error()) -} - func isArtifactNotFound(err error) bool { var httpErr *errorutils.HttpResponseError return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound @@ -399,17 +412,20 @@ type violationRow struct { Detail string `json:"detail"` ViolationId string `json:"violation_id"` Link string `json:"link,omitempty"` + Platform string `json:"platform,omitempty"` issueKey string } type Result struct { Repo string `json:"repo"` + RequestedRepo string `json:"requested_repo,omitempty"` Path string `json:"path"` Sha256 string `json:"sha256,omitempty"` ScansListLink string `json:"scans_list_link,omitempty"` ScanStatus services.ArtifactDetailedStatus `json:"scan_status"` DownloadStatus string `json:"download_status"` StatusReason string `json:"status_reason,omitempty"` + Platforms []PlatformStatus `json:"platforms,omitempty"` Violations []violationRow `json:"violations"` } @@ -473,15 +489,7 @@ func buildResult(repo, path, sha256, platformUrl, packageId, version string, che } } - sort.SliceStable(result.Violations, func(i, j int) bool { - if result.Violations[i].Blocking != result.Violations[j].Blocking { - return result.Violations[i].Blocking - } - if result.Violations[i].SeverityNumValue != result.Violations[j].SeverityNumValue { - return result.Violations[i].SeverityNumValue > result.Violations[j].SeverityNumValue - } - return result.Violations[i].issueKey < result.Violations[j].issueKey - }) + sortViolationRows(result.Violations) switch { case checksumMismatch: @@ -499,6 +507,18 @@ func buildResult(repo, path, sha256, platformUrl, packageId, version string, che return result } +func sortViolationRows(rows []violationRow) { + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Blocking != rows[j].Blocking { + return rows[i].Blocking + } + if rows[i].SeverityNumValue != rows[j].SeverityNumValue { + return rows[i].SeverityNumValue > rows[j].SeverityNumValue + } + return rows[i].issueKey < rows[j].issueKey + }) +} + func violationIgnored(policy services.ViolationPolicy, violation services.XrayViolation) bool { if policy.IsIgnored { return true @@ -698,6 +718,9 @@ func printResult(outputFormat format.OutputFormat, result *Result) error { } log.Output(fmt.Sprintf("Artifact: %s/%s", result.Repo, result.Path)) + if result.RequestedRepo != "" { + log.Output(fmt.Sprintf("Resolved From: %s", result.RequestedRepo)) + } if result.Sha256 != "" { log.Output(fmt.Sprintf("Sha256: %s", result.Sha256)) } @@ -709,6 +732,9 @@ func printResult(outputFormat format.OutputFormat, result *Result) error { if result.ScansListLink != "" { log.Output(fmt.Sprintf("Results: %s", result.ScansListLink)) } + for _, platform := range result.Platforms { + log.Output(fmt.Sprintf("Platform: %s %s", platform.Platform, colorizeDownloadStatus(platform.DownloadStatus))) + } // The Link column only earns its place when the terminal can actually render it as a clickable OSC 8 // hyperlink (e.g. iTerm2, VS Code, Windows Terminal) - in a terminal that can't (e.g. Terminal.app), a @@ -740,10 +766,14 @@ func printResult(outputFormat format.OutputFormat, result *Result) error { } func newViolationTableRow(violation violationRow) violationTableRow { + detail := violation.Detail + if violation.Platform != "" { + detail = "[" + violation.Platform + "] " + detail + } return violationTableRow{ Blocking: colorizeBlocking(violation.Blocking), Severity: colorizeSeverity(violation.Severity), - Detail: violation.Detail, + Detail: detail, State: violationState(violation.Ignored), ViolationId: violation.ViolationId, Watch: violation.Watch, diff --git a/commands/xray/downloadstatus/downloadstatus_test.go b/commands/xray/downloadstatus/downloadstatus_test.go index 9ce111e54..da215776d 100644 --- a/commands/xray/downloadstatus/downloadstatus_test.go +++ b/commands/xray/downloadstatus/downloadstatus_test.go @@ -510,14 +510,14 @@ func TestBuildResultUsesIssueIdAndFallsBackToViolationId(t *testing.T) { func TestArtifactSummaryPathsTriesProjectScopeFirst(t *testing.T) { cmd := NewDownloadStatusCommand().SetRepoAndPathCandidates("libs-release-local", nil) - assert.Equal(t, []string{"libs-release-local/com/acme/foo.jar", "default/libs-release-local/com/acme/foo.jar"}, cmd.artifactSummaryPaths("com/acme/foo.jar")) + assert.Equal(t, []string{"libs-release-local/com/acme/foo.jar", "default/libs-release-local/com/acme/foo.jar"}, cmd.artifactSummaryPaths("libs-release-local", "com/acme/foo.jar")) cmd.SetProject("team-a") assert.Equal(t, []string{ "team-a/libs-release-local/com/acme/foo.jar", "libs-release-local/com/acme/foo.jar", "default/libs-release-local/com/acme/foo.jar", - }, cmd.artifactSummaryPaths("com/acme/foo.jar")) + }, cmd.artifactSummaryPaths("libs-release-local", "com/acme/foo.jar")) } func TestIsArtifactNotFound(t *testing.T) { diff --git a/commands/xray/downloadstatus/index.go b/commands/xray/downloadstatus/index.go new file mode 100644 index 000000000..7cb59da61 --- /dev/null +++ b/commands/xray/downloadstatus/index.go @@ -0,0 +1,138 @@ +package downloadstatus + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + artifactoryUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + manifestListFileName = "list.manifest.json" + maxManifestListBytes = 4 << 20 + unknownPlatformArch = "unknown" +) + +type manifestSource interface { + FileInfo(relativePath string) (*artifactoryUtils.FileInfo, error) + ReadRemoteFile(readPath string) (io.ReadCloser, error) +} + +type manifestList struct { + Manifests []struct { + Digest string `json:"digest"` + Platform struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + Variant string `json:"variant"` + } `json:"platform"` + } `json:"manifests"` +} + +type platformManifest struct { + platform string + path string + sha256 string +} + +type PlatformStatus struct { + Platform string `json:"platform"` + Path string `json:"path"` + DownloadStatus string `json:"download_status"` + StatusReason string `json:"status_reason,omitempty"` +} + +type platformResult struct { + platform string + result *Result +} + +func isManifestListPath(path string) bool { + return strings.HasSuffix(path, "/"+manifestListFileName) +} + +// Xray scans each platform manifest of a multi-arch image, and the list itself carries no violations. +func listPlatformManifests(source manifestSource, repo, listPath string) ([]platformManifest, error) { + imagePath := strings.TrimSuffix(listPath, "/"+manifestListFileName) + if cut := strings.LastIndex(imagePath, "/"); cut != -1 { + imagePath = imagePath[:cut] + } + + reader, err := source.ReadRemoteFile(repo + "/" + listPath) + if err != nil { + return nil, err + } + defer func() { + if closeErr := reader.Close(); closeErr != nil { + log.Debug(fmt.Sprintf("Failed closing %s/%s: %s", repo, listPath, closeErr.Error())) + } + }() + content, err := io.ReadAll(io.LimitReader(reader, maxManifestListBytes)) + if err != nil { + return nil, errorutils.CheckError(err) + } + list := manifestList{} + if err = json.Unmarshal(content, &list); err != nil { + return nil, errorutils.CheckErrorf("couldn't parse manifest list %s/%s: %s", repo, listPath, err.Error()) + } + + var manifests []platformManifest + for _, entry := range list.Manifests { + if entry.Digest == "" || entry.Platform.Architecture == unknownPlatformArch { + continue + } + platform := entry.Platform.OS + "/" + entry.Platform.Architecture + if entry.Platform.Variant != "" { + platform += "/" + entry.Platform.Variant + } + path := imagePath + "/" + strings.Replace(entry.Digest, ":", "__", 1) + "/manifest.json" + fileInfo, err := source.FileInfo(repo + "/" + path) + if err != nil { + log.Debug(fmt.Sprintf("Platform manifest %s/%s (%s) is not available: %s", repo, path, platform, err.Error())) + continue + } + manifests = append(manifests, platformManifest{platform: platform, path: path, sha256: fileInfo.Checksums.Sha256}) + } + return manifests, nil +} + +func downloadStatusRank(status string) int { + switch status { + case StatusBlocked: + return 3 + case StatusUnknown: + return 2 + default: + return 1 + } +} + +func mergePlatformResults(result *Result, platforms []platformResult) { + worst, worstPlatform := result, "" + for _, platform := range platforms { + child := platform.result + result.Platforms = append(result.Platforms, PlatformStatus{ + Platform: platform.platform, + Path: child.Path, + DownloadStatus: child.DownloadStatus, + StatusReason: child.StatusReason, + }) + for _, row := range child.Violations { + row.Platform = platform.platform + result.Violations = append(result.Violations, row) + } + if downloadStatusRank(child.DownloadStatus) > downloadStatusRank(worst.DownloadStatus) { + worst, worstPlatform = child, platform.platform + } + } + if worst != result { + result.DownloadStatus = worst.DownloadStatus + result.StatusReason = fmt.Sprintf("%s (%s)", worst.StatusReason, worstPlatform) + result.ScanStatus = worst.ScanStatus + } + sortViolationRows(result.Violations) +} diff --git a/commands/xray/downloadstatus/index_test.go b/commands/xray/downloadstatus/index_test.go new file mode 100644 index 000000000..3d42002cb --- /dev/null +++ b/commands/xray/downloadstatus/index_test.go @@ -0,0 +1,121 @@ +package downloadstatus + +import ( + "errors" + "io" + "strings" + "testing" + + artifactoryUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/xray/services" + "github.com/stretchr/testify/assert" +) + +type fakeManifestSource struct { + fakeLocator + content map[string]string +} + +func (f *fakeManifestSource) ReadRemoteFile(readPath string) (io.ReadCloser, error) { + content, ok := f.content[readPath] + if !ok { + return nil, errors.New("not found") + } + return io.NopCloser(strings.NewReader(content)), nil +} + +const testManifestList = `{ + "schemaVersion": 2, + "manifests": [ + {"digest": "sha256:aaa", "platform": {"os": "linux", "architecture": "amd64"}}, + {"digest": "sha256:bbb", "platform": {"os": "linux", "architecture": "arm64", "variant": "v8"}}, + {"digest": "sha256:ccc", "platform": {"os": "unknown", "architecture": "unknown"}}, + {"digest": "sha256:ddd", "platform": {"os": "linux", "architecture": "s390x"}} + ] +}` + +func TestIsManifestListPath(t *testing.T) { + assert.True(t, isManifestListPath("team/app/1.0/list.manifest.json")) + assert.True(t, isManifestListPath("app/sha256__abc/list.manifest.json")) + assert.False(t, isManifestListPath("team/app/1.0/manifest.json")) + assert.False(t, isManifestListPath("list.manifest.json")) +} + +func TestListPlatformManifests(t *testing.T) { + source := &fakeManifestSource{ + fakeLocator: fakeLocator{files: map[string]artifactoryUtils.FileInfo{ + "docker-cache/team/app/sha256__aaa/manifest.json": fileInfo("docker-cache", "sha-amd64"), + "docker-cache/team/app/sha256__bbb/manifest.json": fileInfo("docker-cache", "sha-arm64"), + }}, + content: map[string]string{"docker-cache/team/app/1.0/list.manifest.json": testManifestList}, + } + manifests, err := listPlatformManifests(source, "docker-cache", "team/app/1.0/list.manifest.json") + assert.NoError(t, err) + assert.Equal(t, []platformManifest{ + {platform: "linux/amd64", path: "team/app/sha256__aaa/manifest.json", sha256: "sha-amd64"}, + {platform: "linux/arm64/v8", path: "team/app/sha256__bbb/manifest.json", sha256: "sha-arm64"}, + }, manifests) +} + +func TestListPlatformManifestsErrors(t *testing.T) { + source := &fakeManifestSource{content: map[string]string{"r/app/1/list.manifest.json": "not json"}} + _, err := listPlatformManifests(source, "r", "app/1/list.manifest.json") + assert.ErrorContains(t, err, "couldn't parse manifest list") + + _, err = listPlatformManifests(source, "r", "app/2/list.manifest.json") + assert.Error(t, err) +} + +func blockingResult(path string) *Result { + return buildResult("repo", path, "", "https://acme.jfrog.io", "gav://a:b", "1", false, + &services.ArtifactStatusResponse{Details: services.ArtifactDetailedStatus{Violations: services.ArtifactScanStatus{Status: services.ArtifactStatusDone}}}, + []services.XrayViolation{{ + Id: "v1", + Severity: "High", + Policies: []services.ViolationPolicy{{PolicyName: "p", Rule: "r", IsBlocking: true}}, + }}) +} + +func allowedResult(path string) *Result { + return buildResult("repo", path, "", "https://acme.jfrog.io", "gav://a:b", "1", false, + &services.ArtifactStatusResponse{Details: services.ArtifactDetailedStatus{Violations: services.ArtifactScanStatus{Status: services.ArtifactStatusDone}}}, nil) +} + +func TestMergePlatformResults(t *testing.T) { + t.Run("blocked platform blocks the list", func(t *testing.T) { + result := allowedResult("app/1/list.manifest.json") + mergePlatformResults(result, []platformResult{ + {platform: "linux/amd64", result: blockingResult("app/sha256__a/manifest.json")}, + {platform: "linux/arm64", result: allowedResult("app/sha256__b/manifest.json")}, + }) + assert.Equal(t, StatusBlocked, result.DownloadStatus) + assert.Contains(t, result.StatusReason, "linux/amd64") + assert.Len(t, result.Platforms, 2) + assert.Equal(t, StatusBlocked, result.Platforms[0].DownloadStatus) + assert.Equal(t, StatusAllowed, result.Platforms[1].DownloadStatus) + assert.Len(t, result.Violations, 1) + assert.Equal(t, "linux/amd64", result.Violations[0].Platform) + }) + + t.Run("all platforms allowed", func(t *testing.T) { + result := allowedResult("app/1/list.manifest.json") + mergePlatformResults(result, []platformResult{{platform: "linux/amd64", result: allowedResult("app/sha256__a/manifest.json")}}) + assert.Equal(t, StatusAllowed, result.DownloadStatus) + assert.Empty(t, result.StatusReason) + }) + + t.Run("no platforms keeps the list result", func(t *testing.T) { + result := allowedResult("app/1/list.manifest.json") + mergePlatformResults(result, nil) + assert.Equal(t, StatusAllowed, result.DownloadStatus) + assert.Empty(t, result.Platforms) + }) + + t.Run("incomplete scan makes the list unknown", func(t *testing.T) { + result := allowedResult("app/1/list.manifest.json") + incomplete := buildResult("repo", "app/sha256__a/manifest.json", "", "https://acme.jfrog.io", "gav://a:b", "1", false, + &services.ArtifactStatusResponse{Details: services.ArtifactDetailedStatus{Violations: services.ArtifactScanStatus{Status: services.ArtifactStatusScanning}}}, nil) + mergePlatformResults(result, []platformResult{{platform: "linux/amd64", result: incomplete}}) + assert.Equal(t, StatusUnknown, result.DownloadStatus) + }) +} diff --git a/commands/xray/downloadstatus/resolve.go b/commands/xray/downloadstatus/resolve.go new file mode 100644 index 000000000..2dec5d238 --- /dev/null +++ b/commands/xray/downloadstatus/resolve.go @@ -0,0 +1,102 @@ +package downloadstatus + +import ( + "fmt" + "strings" + + artifactoryUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + rclassVirtual = "virtual" + maxVirtualDepth = 10 +) + +type artifactLocator interface { + GetRepository(repoKey string, repoDetails interface{}) error + FileInfo(relativePath string) (*artifactoryUtils.FileInfo, error) +} + +type repoConfig struct { + Rclass string `json:"rclass"` + Repositories []string `json:"repositories"` +} + +type locatedArtifact struct { + repo string + path string + sha256 string +} + +// Xray only tracks concrete repos: local ones and the '-cache' repo behind a remote one. +func locateArtifact(locator artifactLocator, repo string, pathCandidates []string) (*locatedArtifact, error) { + found, lastErr := locateInRepo(locator, repo, pathCandidates, map[string]bool{}, 0) + if found != nil { + return found, nil + } + tried := strings.Join(pathCandidates, ", ") + if lastErr == nil { + return nil, errorutils.CheckErrorf("could not find artifact under repo '%s' (tried: %s)", repo, tried) + } + if !isArtifactNotFound(lastErr) { + return nil, lastErr + } + return nil, errorutils.CheckErrorf("could not find artifact under repo '%s' (tried: %s): %s", repo, tried, lastErr.Error()) +} + +func locateInRepo(locator artifactLocator, repo string, pathCandidates []string, visited map[string]bool, depth int) (*locatedArtifact, error) { + if visited[repo] || depth > maxVirtualDepth { + return nil, nil + } + visited[repo] = true + + config := repoConfig{} + if err := locator.GetRepository(repo, &config); err != nil { + log.Debug(fmt.Sprintf("Could not read the configuration of repo '%s', treating it as a concrete repo: %s", repo, err.Error())) + return probeRepo(locator, repo, pathCandidates) + } + if !strings.EqualFold(config.Rclass, rclassVirtual) { + return probeRepo(locator, repo, pathCandidates) + } + + log.Debug(fmt.Sprintf("Repo '%s' is virtual, looking for the artifact in its members: %s", repo, strings.Join(config.Repositories, ", "))) + var lastErr error + for _, member := range config.Repositories { + found, err := locateInRepo(locator, member, pathCandidates, visited, depth+1) + if found != nil { + log.Debug(fmt.Sprintf("Artifact found in member repo '%s' of virtual repo '%s'", found.repo, repo)) + return found, nil + } + if err != nil { + lastErr = err + } + } + return nil, lastErr +} + +// For a remote repo the storage API answers with the '-cache' repo that holds the file. +func probeRepo(locator artifactLocator, repo string, pathCandidates []string) (*locatedArtifact, error) { + var lastErr error + for _, candidate := range pathCandidates { + fileInfo, err := locator.FileInfo(repo + "/" + candidate) + if err == nil { + resolvedRepo := fileInfo.Repo + if resolvedRepo == "" { + resolvedRepo = repo + } + if resolvedRepo != repo { + log.Debug(fmt.Sprintf("Repo '%s' resolved to '%s'", repo, resolvedRepo)) + } + return &locatedArtifact{repo: resolvedRepo, path: candidate, sha256: fileInfo.Checksums.Sha256}, nil + } + if !isArtifactNotFound(err) { + log.Debug(fmt.Sprintf("File info for %s/%s failed: %s", repo, candidate, err.Error())) + return nil, err + } + log.Debug(fmt.Sprintf("Artifact %s/%s was not found", repo, candidate)) + lastErr = err + } + return nil, lastErr +} diff --git a/commands/xray/downloadstatus/resolve_test.go b/commands/xray/downloadstatus/resolve_test.go new file mode 100644 index 000000000..1896539c6 --- /dev/null +++ b/commands/xray/downloadstatus/resolve_test.go @@ -0,0 +1,149 @@ +package downloadstatus + +import ( + "encoding/json" + "errors" + "net/http" + "testing" + + artifactoryUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/stretchr/testify/assert" +) + +type fakeLocator struct { + configs map[string]repoConfig + files map[string]artifactoryUtils.FileInfo + errors map[string]error + probed []string +} + +func (f *fakeLocator) GetRepository(repoKey string, repoDetails interface{}) error { + config, ok := f.configs[repoKey] + if !ok { + return &errorutils.HttpResponseError{StatusCode: http.StatusBadRequest} + } + data, err := json.Marshal(config) + if err != nil { + return err + } + return json.Unmarshal(data, repoDetails) +} + +func (f *fakeLocator) FileInfo(relativePath string) (*artifactoryUtils.FileInfo, error) { + f.probed = append(f.probed, relativePath) + if err, ok := f.errors[relativePath]; ok { + return nil, err + } + info, ok := f.files[relativePath] + if !ok { + return nil, &errorutils.HttpResponseError{StatusCode: http.StatusNotFound} + } + return &info, nil +} + +func fileInfo(repo, sha256 string) artifactoryUtils.FileInfo { + info := artifactoryUtils.FileInfo{Repo: repo} + info.Checksums.Sha256 = sha256 + return info +} + +func TestLocateArtifact(t *testing.T) { + configs := map[string]repoConfig{ + "libs-local": {Rclass: "local"}, + "central": {Rclass: "remote"}, + "inner": {Rclass: "virtual", Repositories: []string{"libs-local", "central"}}, + "libs-virtual": {Rclass: "virtual", Repositories: []string{"empty-local", "inner", "central"}}, + "loop-a": {Rclass: "virtual", Repositories: []string{"loop-b"}}, + "loop-b": {Rclass: "virtual", Repositories: []string{"loop-a"}}, + "empty-local": {Rclass: "local"}, + } + files := map[string]artifactoryUtils.FileInfo{ + "libs-local/a.jar": fileInfo("libs-local", "sha-local"), + "central/b.jar": fileInfo("central-cache", "sha-remote"), + "central-cache/b.jar": fileInfo("central-cache", ""), + } + + tests := []struct { + name string + repo string + candidates []string + expectedRepo string + expectedPath string + expectedSha string + expectNotFound bool + }{ + {name: "local repo", repo: "libs-local", candidates: []string{"a.jar"}, expectedRepo: "libs-local", expectedPath: "a.jar", expectedSha: "sha-local"}, + {name: "remote repo resolves to its cache", repo: "central", candidates: []string{"b.jar"}, expectedRepo: "central-cache", expectedPath: "b.jar", expectedSha: "sha-remote"}, + {name: "virtual repo with local member", repo: "inner", candidates: []string{"a.jar"}, expectedRepo: "libs-local", expectedPath: "a.jar", expectedSha: "sha-local"}, + {name: "virtual repo with remote member", repo: "inner", candidates: []string{"b.jar"}, expectedRepo: "central-cache", expectedPath: "b.jar", expectedSha: "sha-remote"}, + {name: "nested virtual repo", repo: "libs-virtual", candidates: []string{"a.jar"}, expectedRepo: "libs-local", expectedPath: "a.jar", expectedSha: "sha-local"}, + {name: "second path candidate", repo: "inner", candidates: []string{"missing.jar", "a.jar"}, expectedRepo: "libs-local", expectedPath: "a.jar", expectedSha: "sha-local"}, + {name: "cache repo key is treated as concrete", repo: "central-cache", candidates: []string{"b.jar"}, expectedRepo: "central-cache", expectedPath: "b.jar"}, + {name: "not found in any member", repo: "inner", candidates: []string{"missing.jar"}, expectNotFound: true}, + {name: "virtual cycle terminates", repo: "loop-a", candidates: []string{"a.jar"}, expectNotFound: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + locator := &fakeLocator{configs: configs, files: files} + located, err := locateArtifact(locator, test.repo, test.candidates) + if test.expectNotFound { + assert.ErrorContains(t, err, "could not find artifact under repo '"+test.repo+"'") + return + } + assert.NoError(t, err) + assert.Equal(t, test.expectedRepo, located.repo) + assert.Equal(t, test.expectedPath, located.path) + assert.Equal(t, test.expectedSha, located.sha256) + }) + } +} + +func TestLocateArtifactVirtualMemberOrder(t *testing.T) { + locator := &fakeLocator{ + configs: map[string]repoConfig{ + "v": {Rclass: "virtual", Repositories: []string{"l1", "l2"}}, + "l1": {Rclass: "local"}, + "l2": {Rclass: "local"}, + }, + files: map[string]artifactoryUtils.FileInfo{ + "l1/a.jar": fileInfo("l1", "one"), + "l2/a.jar": fileInfo("l2", "two"), + }, + } + located, err := locateArtifact(locator, "v", []string{"a.jar"}) + assert.NoError(t, err) + assert.Equal(t, "l1", located.repo) + assert.Equal(t, []string{"l1/a.jar"}, locator.probed) +} + +func TestLocateArtifactSkipsUnreadableMember(t *testing.T) { + locator := &fakeLocator{ + configs: map[string]repoConfig{ + "v": {Rclass: "virtual", Repositories: []string{"l1", "l2"}}, + "l1": {Rclass: "local"}, + "l2": {Rclass: "local"}, + }, + files: map[string]artifactoryUtils.FileInfo{"l2/a.jar": fileInfo("l2", "two")}, + errors: map[string]error{"l1/a.jar": &errorutils.HttpResponseError{StatusCode: http.StatusForbidden}}, + } + located, err := locateArtifact(locator, "v", []string{"a.jar"}) + assert.NoError(t, err) + assert.Equal(t, "l2", located.repo) +} + +func TestLocateArtifactSurfacesNonNotFoundError(t *testing.T) { + locator := &fakeLocator{ + configs: map[string]repoConfig{"l1": {Rclass: "local"}}, + errors: map[string]error{"l1/a.jar": errors.New("connection reset")}, + } + _, err := locateArtifact(locator, "l1", []string{"a.jar"}) + assert.EqualError(t, err, "connection reset") +} + +func TestLocateArtifactUnknownRepoConfigFallsBackToProbing(t *testing.T) { + locator := &fakeLocator{files: map[string]artifactoryUtils.FileInfo{"repo/a.jar": fileInfo("repo", "sha")}} + located, err := locateArtifact(locator, "repo", []string{"a.jar"}) + assert.NoError(t, err) + assert.Equal(t, "repo", located.repo) +}