From 8e299a8944ce6e3fe2b812a9ae0003d837780fa4 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Wed, 5 Aug 2026 10:11:46 +0530 Subject: [PATCH 1/6] RTECO-945: Add Alpine APK (jf apk) command and setup support Adds native Alpine Linux APK support to jfrog-cli-artifactory: - artifactory/commands/alpine: the jf apk wrapper that runs the native apk binary against an Artifactory-backed repository, collecting Build Info via build-info-go AlpineModule (install-side dependency graph with checksums, scopes and requestedBy chains) and uploading downloaded archives. - artifactory/commands/setup: jf setup apk configuration - writes the repo definition and signing key to the apk config, creating credential-bearing files owner-only (umask 077) and restoring prior content on failure. - go.mod: bump build-info-go and jfrog-cli-core to the commits carrying the Alpine build-info APIs (SetDownloadsDir, apk module) and the apk ProjectType. Co-authored-by: Cursor --- artifactory/commands/alpine/apkcommand.go | 785 ++++++++++++++++++ .../commands/alpine/apkcommand_test.go | 338 ++++++++ artifactory/commands/alpine/apkupload.go | 769 +++++++++++++++++ artifactory/commands/alpine/apkupload_test.go | 167 ++++ artifactory/commands/alpine/credentials.go | 60 ++ artifactory/commands/setup/setup.go | 415 ++++++++- artifactory/commands/setup/setup_test.go | 157 ++++ go.mod | 4 +- go.sum | 6 + 9 files changed, 2696 insertions(+), 5 deletions(-) create mode 100644 artifactory/commands/alpine/apkcommand.go create mode 100644 artifactory/commands/alpine/apkcommand_test.go create mode 100644 artifactory/commands/alpine/apkupload.go create mode 100644 artifactory/commands/alpine/apkupload_test.go create mode 100644 artifactory/commands/alpine/credentials.go diff --git a/artifactory/commands/alpine/apkcommand.go b/artifactory/commands/alpine/apkcommand.go new file mode 100644 index 00000000..5f341dd7 --- /dev/null +++ b/artifactory/commands/alpine/apkcommand.go @@ -0,0 +1,785 @@ +package alpine + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + + biUtils "github.com/jfrog/build-info-go/build/utils" + "github.com/jfrog/build-info-go/entities" + artutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + coreutils "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/artifactory" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "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" +) + +var buildInfoSubcmds = map[string]bool{ + "add": true, + "upgrade": true, +} + +// jfFlagSet is the set of jf-specific flags that must be stripped before forwarding +// args to the native apk binary. Each flag may appear as "--flag value" or "--flag=value". +var jfFlagSet = map[string]bool{ + "--build-name": true, + "--build-number": true, + "--project": true, + "--module": true, + "--server-id": true, + "--repo": true, + "--alpine-version": true, + "--user": true, + "--password": true, +} + +// ApkCommand wraps the native apk binary with credential injection and Build Info collection. +type ApkCommand struct { + commandName string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration + repoKey string + alpineVersion string + apkArgs []string + username string + password string +} + +// NewApkCommand constructs an ApkCommand for the given apk subcommand. +func NewApkCommand(commandName string) *ApkCommand { + return &ApkCommand{commandName: commandName} +} + +// SetArgs sets the arguments forwarded to the native apk binary. +func (apkCmd *ApkCommand) SetArgs(args []string) *ApkCommand { + apkCmd.apkArgs = args + return apkCmd +} + +// SetServerDetails sets the Artifactory server config. +func (apkCmd *ApkCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApkCommand { + apkCmd.serverDetails = serverDetails + return apkCmd +} + +// SetBuildConfiguration sets the build configuration. +func (apkCmd *ApkCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApkCommand { + apkCmd.buildConfiguration = bc + return apkCmd +} + +// SetRepo sets the Artifactory Alpine repository key. +func (apkCmd *ApkCommand) SetRepo(repoKey string) *ApkCommand { + apkCmd.repoKey = repoKey + return apkCmd +} + +// SetAlpineVersion sets the Alpine release tag (e.g. "v3.20"). +func (apkCmd *ApkCommand) SetAlpineVersion(version string) *ApkCommand { + apkCmd.alpineVersion = version + return apkCmd +} + +func alpineModuleID(configured, repoKey, arch, alpineVersion string) string { + if configured != "" { + return configured + } + if repoKey == "" { + repoKey = "apk" + } + if arch == "" { + arch = "unknown" + } + if alpineVersion == "" { + alpineVersion = "unknown" + } else if !strings.HasPrefix(alpineVersion, "v") { + alpineVersion = "v" + alpineVersion + } + return fmt.Sprintf("%s:%s:%s", repoKey, arch, alpineVersion) +} + +// shouldIsolateRepo reports whether this command should target its --repo on the selected server +// via a temporary repositories file instead of /etc/apk/repositories. It isolates only when a +// --repo is set AND the selected server (serverDetails.ServerId) differs from the default server +// that /etc/apk/repositories is assumed to be configured with (via `jf setup apk`). This runs the +// one-off command against the requested server while leaving the default server's persistent +// configuration untouched. +func (apkCmd *ApkCommand) shouldIsolateRepo() bool { + if apkCmd.repoKey == "" || apkCmd.serverDetails == nil || apkCmd.serverDetails.ServerId == "" { + return false + } + defaultConf, err := config.GetDefaultServerConf() + if err != nil || defaultConf == nil { + // No default server to compare against — treat the explicitly selected server as isolated. + return true + } + return apkCmd.serverDetails.ServerId != defaultConf.ServerId +} + +// SetUsername sets the username CLI flag override. +func (apkCmd *ApkCommand) SetUsername(username string) *ApkCommand { + apkCmd.username = username + return apkCmd +} + +// SetPassword sets the password CLI flag override. +func (apkCmd *ApkCommand) SetPassword(password string) *ApkCommand { + apkCmd.password = password + return apkCmd +} + +// CommandName satisfies the Command interface. +func (apkCmd *ApkCommand) CommandName() string { + return apkCmd.commandName +} + +// ServerDetails satisfies the Command interface. +func (apkCmd *ApkCommand) ServerDetails() (*config.ServerDetails, error) { + return apkCmd.serverDetails, nil +} + +// Run executes the pre-exec, exec, and post-exec phases of the apk wrapper. +func (apkCmd *ApkCommand) Run() error { + // --repo is an explicit repository selection. Validate it before invoking native apk so + // every wrapped subcommand fails consistently and clearly for an unknown repository. + if apkCmd.repoKey != "" { + if err := ensureRepoExists(apkCmd.repoKey, apkCmd.serverDetails); err != nil { + return err + } + } + + apkPath, err := exec.LookPath("apk") + if err != nil { + return errorutils.CheckErrorf("'apk' binary not found. Is this an Alpine Linux environment?") + } + warnIfApkTooOld() + + collectBuildInfo, err := apkCmd.buildConfiguration.IsCollectBuildInfo() + if err != nil { + return err + } + needsBuildInfo := buildInfoSubcmds[apkCmd.commandName] && collectBuildInfo + + nativeArgs := stripJFFlags(apkCmd.apkArgs) + requestedPkgs := extractPackageNames(nativeArgs) + + noCache := containsFlag(nativeArgs, "--no-cache") + userCacheDir := flagValue(nativeArgs, "--cache-dir") + + var preSnapshot []biUtils.AlpinePackage + if needsBuildInfo { + preSnapshot, err = biUtils.ListInstalledPackages() + if err != nil { + log.Warn("Cannot list installed packages — Build Info not captured:", err) + needsBuildInfo = false + } + } + + env, err := apkCmd.buildEnvWithHTTPAuth() + if err != nil { + return err + } + + var cacheDir string + var ownCacheDir bool + switch { + case noCache: + if needsBuildInfo { + log.Warn("--no-cache prevents apk from keeping the downloaded archives, so checksums " + + "cannot be computed locally and will only be filled for packages found in the " + + "Artifactory repository. Drop --no-cache for complete Build Info.") + } + log.Debug("--no-cache detected: skipping temp cache dir; local checksums require a cached .apk.") + case userCacheDir != "": + cacheDir = userCacheDir + log.Debug("Reusing caller-provided --cache-dir for checksum collection:", cacheDir) + case needsBuildInfo: + cacheDir, err = createTempDir() + if err != nil { + log.Warn("Could not create temp cache dir — checksums may be incomplete:", err) + } else { + ownCacheDir = true + } + } + + if apkCmd.shouldIsolateRepo() { + repoFile, buildErr := apkCmd.writeIsolatedRepositoriesFile() + if buildErr != nil { + log.Warn("Could not build an isolated repositories file for --repo/--server-id; falling back to /etc/apk/repositories:", buildErr) + } else { + defer func() { _ = os.Remove(repoFile) }() + nativeArgs = append([]string{"--repositories-file", repoFile}, nativeArgs...) + log.Debug("Using isolated repositories file for this command:", repoFile) + } + } + + args := nativeArgs + if ownCacheDir && cacheDir != "" { + args = append([]string{"--cache-dir", cacheDir}, args...) + } + fullArgs := append([]string{apkCmd.commandName}, args...) + exitCode, err := runWithPackageManager(apkPath, fullArgs, env) + if ownCacheDir && cacheDir != "" && (err != nil || exitCode != 0) { + _ = os.RemoveAll(cacheDir) + } + if err != nil { + return err + } + if exitCode != 0 { + return coreutils.CliError{ExitCode: coreutils.ExitCode{Code: exitCode}} + } + defer func() { + if ownCacheDir { + _ = os.RemoveAll(cacheDir) + } + }() + + if collectBuildInfo && !buildInfoSubcmds[apkCmd.commandName] { + log.Warn(unsupportedBuildInfoMessage(apkCmd.commandName)) + return nil + } + if !needsBuildInfo { + return nil + } + downloadsDir := "" + if ownCacheDir { + downloadsDir = cacheDir + } + apkCmd.collectBuildInfo(preSnapshot, cacheDir, downloadsDir, requestedPkgs) + return nil +} + +func unsupportedBuildInfoMessage(commandName string) string { + return fmt.Sprintf( + "Build Info flags were provided, but Build Info collection is not available for 'apk %s'. "+ + "Only 'add', 'upgrade', and 'upload' support Build Info. The command was executed as a passthrough.", + commandName, + ) +} + +// buildEnvWithHTTPAuth returns the current process environment with HTTP_AUTH injected for the apk subprocess. +func (apkCmd *ApkCommand) buildEnvWithHTTPAuth() ([]string, error) { + env := os.Environ() + + rtURL := "" + if apkCmd.serverDetails != nil { + rtURL = apkCmd.serverDetails.GetArtifactoryUrl() + } + + // If there is no server config but --user/--password + --repo are provided, try to derive + // the Artifactory URL from /etc/apk/repositories so we can still build a valid HTTP_AUTH + // value without requiring jf c add to have been run. + if rtURL == "" && (apkCmd.username != "" || apkCmd.password != "") { + if apkCmd.repoKey != "" { + if repoURL, err := readRepoURLFromRepositoriesFile(apkCmd.repoKey); err == nil && repoURL != "" { + rtURL = repoURL + log.Debug("HTTP_AUTH: derived Artifactory URL from /etc/apk/repositories:", rtURL) + } + } + if rtURL == "" { + log.Warn("--user/--password provided but no Artifactory URL could be determined. Use --server-id or run jf setup apk first.") + return env, nil + } + } + + if rtURL == "" { + log.Warn("No JFrog server configured — skipping HTTP_AUTH injection. Run: jf c add") + return env, nil + } + + // Filter out env vars matching the JFROG_CLI_ENV_EXCLUDE pattern to avoid secret leaks + // in the subprocess environment. + env = filterSecretEnvVars(env) + + // Explicit --user/--password flags override any pre-set HTTP_AUTH; a stored/default + // server config does not, so a user-provided HTTP_AUTH is otherwise honoured as-is. + userExplicitFlags := apkCmd.username != "" || apkCmd.password != "" + + existingHTTPAuth := os.Getenv("HTTP_AUTH") + if existingHTTPAuth != "" { + if userExplicitFlags { + log.Warn("HTTP_AUTH is already set in your environment. Overriding it with the credentials from the provided flags/server config.") + } else { + // User did not pass explicit flags — honour their pre-set HTTP_AUTH. + log.Debug("HTTP_AUTH already set in environment and no explicit flags provided — keeping existing value.") + return env, nil + } + } + + var username, password string + if apkCmd.serverDetails != nil { + username, password = resolveHTTPAuthCredentials(apkCmd.serverDetails, apkCmd.username, apkCmd.password) + } else { + // No server config — use the explicitly provided flags directly. + username, password = apkCmd.username, apkCmd.password + } + + httpAuth, err := buildHTTPAuth(rtURL, username, password) + if err != nil { + return nil, err + } + + log.Debug("HTTP_AUTH=basic:::***") + env = append(env, "HTTP_AUTH="+httpAuth) + return env, nil +} + +// readRepoURLFromRepositoriesFile scans /etc/apk/repositories and returns the first URL +// that contains repoKey as a path segment, so we can derive the Artifactory hostname when +// no server config is present but --repo + --user/--password were provided. +func readRepoURLFromRepositoriesFile(repoKey string) (string, error) { + f, err := os.Open("/etc/apk/repositories") + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, "/"+repoKey+"/") || strings.HasSuffix(line, "/"+repoKey) { + return line, nil + } + } + return "", scanner.Err() +} + +// writeIsolatedRepositoriesFile writes a temporary apk repositories file containing only the +// selected server's Alpine repo (with credentials embedded in the URL). It is passed to apk via +// --repositories-file so a single command targets that server/repo without reading or mutating +// the persistent /etc/apk/repositories. The caller is responsible for removing the returned path. +func (apkCmd *ApkCommand) writeIsolatedRepositoriesFile() (string, error) { + if apkCmd.serverDetails == nil { + return "", errorutils.CheckErrorf("no server configured for isolated repository access") + } + if apkCmd.repoKey == "" { + return "", errorutils.CheckErrorf("--repo is required to build an isolated repositories file") + } + rtURL := strings.TrimRight(apkCmd.serverDetails.GetArtifactoryUrl(), "/") + if rtURL == "" { + return "", errorutils.CheckErrorf("the selected server has no Artifactory URL") + } + + // Resolve the Alpine version (flag > host /etc/alpine-release) and normalize to the + // canonical "v"-prefixed form so the path matches what Artifactory indexes. + version := apkCmd.alpineVersion + if version == "" { + version = detectSystemAlpineVersion() + } + if version != "" && !strings.HasPrefix(version, "v") { + version = "v" + version + } + + var repoURL string + if version != "" { + repoURL = fmt.Sprintf("%s/%s/%s/main/", rtURL, apkCmd.repoKey, version) + } else { + repoURL = fmt.Sprintf("%s/%s/", rtURL, apkCmd.repoKey) + } + + // Embed credentials in the URL so apk authenticates directly from the temp file. + username, password := resolveHTTPAuthCredentials(apkCmd.serverDetails, apkCmd.username, apkCmd.password) + if username != "" || password != "" { + if parsed, perr := url.Parse(repoURL); perr == nil { + parsed.User = url.UserPassword(username, password) + repoURL = parsed.String() + } + } + + f, err := os.CreateTemp("", "jf-apk-repositories-*") + if err != nil { + return "", errorutils.CheckError(err) + } + defer func() { _ = f.Close() }() + if _, err = f.WriteString(repoURL + "\n"); err != nil { + _ = os.Remove(f.Name()) + return "", errorutils.CheckError(err) + } + // The file embeds a secret — lock it down. + if err = os.Chmod(f.Name(), 0600); err != nil { + _ = os.Remove(f.Name()) + return "", errorutils.CheckError(err) + } + return f.Name(), nil +} + +// buildHTTPAuth constructs the HTTP_AUTH=basic::: string for apk-tools. +func buildHTTPAuth(rtURL, username, password string) (string, error) { + parsed, err := url.Parse(rtURL) + if err != nil { + return "", errorutils.CheckErrorf("invalid Artifactory URL %q: %w", rtURL, err) + } + host := parsed.Hostname() + return fmt.Sprintf("basic:%s:%s:%s", host, username, password), nil +} + +func runWithPackageManager(apkPath string, args []string, env []string) (int, error) { + cmd := exec.Command(apkPath, args...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + var stderrBuf bytes.Buffer + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + emitSignatureHint(stderrBuf.String()) + code := exitErr.ExitCode() + if code < 0 { + // The process was terminated by a signal; ExitCode() returns -1, which is + // not a valid process exit status. Report a conventional failure code. + code = 1 + } + return code, nil + } + return 1, err + } + return 0, nil +} + +func emitSignatureHint(stderr string) { + sigPatterns := []string{ + "UNTRUSTED signature", + "WARNING: Ignoring APKINDEX", + "signature error", + } + for _, pattern := range sigPatterns { + if strings.Contains(stderr, pattern) { + log.Warn("Signature verification failed. Fix: jf setup apk") + return + } + } +} + +// warnIfApkTooOld emits a warning when the installed apk-tools version is older than 2.12, +// the first release with HTTP_AUTH support for authenticated repository access. +func warnIfApkTooOld() { + out, err := exec.Command("apk", "--version").Output() + if err != nil { + return + } + // Output format: "apk-tools 2.12.14, compiled for x86_64" + fields := strings.Fields(string(out)) + if len(fields) < 2 { + return + } + parts := strings.SplitN(fields[1], ".", 3) + if len(parts) < 2 { + return + } + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return + } + if major < 2 || (major == 2 && minor < 12) { + log.Warn(fmt.Sprintf("apk version %s.%d detected — HTTP_AUTH injection may not be supported. Upgrade to apk-tools >= 2.12.", parts[0], minor)) + } +} + +func (apkCmd *ApkCommand) collectBuildInfo(preSnapshot []biUtils.AlpinePackage, cacheDir, downloadsDir string, requestedPkgs []string) { + buildName, nameErr := apkCmd.buildConfiguration.GetBuildName() + if nameErr != nil || buildName == "" { + log.Debug("Build name not set — skipping Build Info capture for apk") + return + } + buildNumber, numErr := apkCmd.buildConfiguration.GetBuildNumber() + if numErr != nil || buildNumber == "" { + log.Debug("Build number not set — skipping Build Info capture for apk") + return + } + + buildObj, err := buildUtils.PrepareBuildPrerequisites(apkCmd.buildConfiguration) + if err != nil { + log.Warn("Build Info publish failed:", err) + return + } + + alpineVersion := apkCmd.alpineVersion + if alpineVersion == "" { + alpineVersion = detectSystemAlpineVersion() + } + moduleID := alpineModuleID(apkCmd.buildConfiguration.GetModule(), apkCmd.repoKey, detectSystemArch(), alpineVersion) + effectivePreSnapshot := excludeRequestedPackages(preSnapshot, requestedPkgs) + + alpineModule := buildObj.AddAlpineModule(moduleID, apkCmd.repoKey, alpineVersion) + alpineModule.SetPreSnapshot(effectivePreSnapshot) + alpineModule.SetCacheDir(cacheDir) + alpineModule.SetDownloadsDir(downloadsDir) + alpineModule.SetRequestedPackages(requestedPkgs) + + deps, err := alpineModule.CollectDependencies() + if err != nil { + log.Warn("Build Info collection failed:", err) + return + } + + if len(deps) == 0 && len(requestedPkgs) > 0 { + log.Warn(fmt.Sprintf( + "Build Info completeness check: 0 dependencies recorded for packages %v. "+ + "The pre/post snapshot diff may be empty (all packages were already installed).", + requestedPkgs, + )) + } else { + log.Debug(fmt.Sprintf("Build Info completeness check: %d dep(s) recorded.", len(deps))) + } + + if apkCmd.serverDetails != nil && apkCmd.repoKey != "" { + deps = apkCmd.enrichChecksumsFromAQL(deps) + } + + if saveErr := alpineModule.SaveBuildInfo(deps); saveErr != nil { + log.Warn("Build Info save failed:", saveErr) + } +} + +func (apkCmd *ApkCommand) enrichChecksumsFromAQL(deps []entities.Dependency) []entities.Dependency { + var missing []int + for i, dep := range deps { + if dep.Sha256 == "" && dep.Sha1 == "" { + missing = append(missing, i) + } + } + if len(missing) == 0 { + return deps + } + + sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, -1, 0, false) + if err != nil { + log.Debug("Could not create Artifactory service manager for AQL checksum enrichment:", err) + return deps + } + + return enrichDepsChecksumsFromAQL(deps, missing, sm, apkCmd.repoKey) +} + +func enrichDepsChecksumsFromAQL(deps []entities.Dependency, missing []int, sm artifactory.ArtifactoryServicesManager, repoKey string) []entities.Dependency { + fileToDep := make(map[string]int, len(missing)) + names := make([]string, 0, len(missing)) + for _, i := range missing { + fileName := apkFileNameFromID(deps[i].Id) + fileToDep[fileName] = i + names = append(names, fmt.Sprintf(`{"name":%s}`, aqlJSONString(fileName))) + } + aqlQuery := fmt.Sprintf( + `{"$and":[{"$or":[{"repo":%s},{"repo":%s}]},{"$or":[%s]}]}`, + aqlJSONString(repoKey), + aqlJSONString(repoKey+"-cache"), + strings.Join(names, ","), + ) + log.Debug(fmt.Sprintf("AQL checksum enrichment: querying %d missing dep(s) in repo %s", len(missing), repoKey)) + + reader, err := sm.SearchFiles(services.SearchParams{ + CommonParams: &specutils.CommonParams{Aql: specutils.Aql{ItemsFind: aqlQuery}}, + }) + if err != nil { + log.Debug("AQL checksum enrichment failed:", err) + return deps + } + defer func() { _ = reader.Close() }() + + resolved := 0 + for item := new(specutils.ResultItem); reader.NextRecord(item) == nil; item = new(specutils.ResultItem) { + i, ok := fileToDep[item.Name] + if !ok { + continue + } + deps[i].Sha1 = item.Actual_Sha1 + deps[i].Sha256 = item.Sha256 + deps[i].Md5 = item.Actual_Md5 + resolved++ + } + if resolved > 0 { + log.Info(fmt.Sprintf("AQL enrichment: resolved checksums for %d/%d missing dep(s)", resolved, len(missing))) + } + return deps +} + +func aqlJSONString(s string) string { + b, err := json.Marshal(s) + if err != nil { + return `""` + } + return string(b) +} + +func apkFileNameFromID(id string) string { + return strings.Replace(id, ":", "-", 1) + ".apk" +} + +func excludeRequestedPackages(snapshot []biUtils.AlpinePackage, requestedPkgs []string) []biUtils.AlpinePackage { + requested := make(map[string]bool, len(requestedPkgs)) + for _, name := range requestedPkgs { + if name != "" { + requested[name] = true + } + } + if len(requested) == 0 { + return snapshot + } + filtered := snapshot[:0:0] + for _, pkg := range snapshot { + if !requested[pkg.Name] { + filtered = append(filtered, pkg) + } + } + return filtered +} + +// stripJFFlags removes jf-specific flags (and their values) from the args slice so that +// only native apk flags are forwarded to the apk binary. +// +// Supported forms: +// - "--flag value" (two separate tokens) +// - "--flag=value" (single token with =) +func stripJFFlags(args []string) []string { + result := make([]string, 0, len(args)) + skip := false + for _, arg := range args { + if skip { + skip = false + continue + } + // "--flag=value" form — strip the whole token. + if idx := strings.Index(arg, "="); idx != -1 { + if jfFlagSet[arg[:idx]] { + continue + } + } + // "--flag" form — mark next token to be skipped. + if jfFlagSet[arg] { + skip = true + continue + } + result = append(result, arg) + } + return result +} + +// containsFlag reports whether args contains the exact flag string. +func containsFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +// flagValue returns the value of a --flag=value or --flag value pair, or "" if not found. +func flagValue(args []string, flag string) string { + prefix := flag + "=" + for i, a := range args { + if strings.HasPrefix(a, prefix) { + return strings.TrimPrefix(a, prefix) + } + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// extractPackageNames returns the non-flag tokens from the args list, +// which represent the package names the user explicitly requested. +// apkValueFlags are native apk flags that consume the following token as their value. +// Their value tokens must not be mistaken for requested package names. +var apkValueFlags = map[string]bool{ + "--cache-dir": true, "--cache-max-age": true, "--keys-dir": true, + "--repositories-file": true, "--arch": true, "--wait": true, + "--repository": true, "-X": true, "--root": true, "-p": true, + "--virtual": true, "-t": true, +} + +func extractPackageNames(args []string) []string { + var pkgs []string + skipNext := false + for _, arg := range args { + if skipNext { + skipNext = false + continue + } + if strings.HasPrefix(arg, "-") { + // `--flag=value` carries its own value; `--flag value` consumes the next token. + if !strings.Contains(arg, "=") && apkValueFlags[arg] { + skipNext = true + } + continue + } + if arg != "" { + pkgs = append(pkgs, arg) + } + } + return pkgs +} + +// filterSecretEnvVars removes environment variables whose names match common secret patterns +// (password, secret, token, key) from the env slice so they are not passed to the apk subprocess +// and are not captured in build-info environment records. +func filterSecretEnvVars(env []string) []string { + excludePattern := os.Getenv(coreutils.EnvExclude) + if excludePattern == "" { + excludePattern = "*password*;*psw*;*secret*;*key*;*token*;*auth*" + } + excludePattern = strings.ReplaceAll(excludePattern, ",", ";") + patterns := strings.Split(strings.ToLower(excludePattern), ";") + + filtered := make([]string, 0, len(env)) + for _, entry := range env { + name := strings.ToLower(strings.SplitN(entry, "=", 2)[0]) + excluded := false + for _, pat := range patterns { + pat = strings.TrimSpace(pat) + if pat == "" { + continue + } + if matchGlob(pat, name) { + excluded = true + break + } + } + if !excluded { + filtered = append(filtered, entry) + } + } + return filtered +} + +// matchGlob performs a simple glob match where '*' matches any substring. +func matchGlob(pattern, s string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == s + } + if !strings.HasPrefix(s, parts[0]) { + return false + } + s = s[len(parts[0]):] + for i := 1; i < len(parts)-1; i++ { + idx := strings.Index(s, parts[i]) + if idx == -1 { + return false + } + s = s[idx+len(parts[i]):] + } + last := parts[len(parts)-1] + return last == "" || strings.HasSuffix(s, last) +} + +// createTempDir creates a temporary directory and returns its path. +func createTempDir() (string, error) { + return os.MkdirTemp("", "apk-cache-*") +} diff --git a/artifactory/commands/alpine/apkcommand_test.go b/artifactory/commands/alpine/apkcommand_test.go new file mode 100644 index 00000000..b4f3dab5 --- /dev/null +++ b/artifactory/commands/alpine/apkcommand_test.go @@ -0,0 +1,338 @@ +package alpine + +import ( + "net/http" + "net/http/httptest" + "testing" + + biUtils "github.com/jfrog/build-info-go/build/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── stripJFFlags ───────────────────────────────────────────────────────────── + +func TestStripJFFlags_RemovesBuildNameAndNumber(t *testing.T) { + args := []string{"add", "--build-name", "my-build", "--build-number", "1", "curl"} + got := stripJFFlags(args) + assert.Equal(t, []string{"add", "curl"}, got) +} + +func TestStripJFFlags_RemovesEqualFormFlags(t *testing.T) { + args := []string{"add", "--build-name=my-build", "--repo=alpine-local", "curl"} + got := stripJFFlags(args) + assert.Equal(t, []string{"add", "curl"}, got) +} + +func TestStripJFFlags_PreservesNativeFlags(t *testing.T) { + args := []string{"add", "--no-cache", "--update-cache", "curl"} + got := stripJFFlags(args) + assert.Equal(t, []string{"add", "--no-cache", "--update-cache", "curl"}, got) +} + +func TestStripJFFlags_AllJFFlags(t *testing.T) { + args := []string{ + "add", + "--build-name", "b", "--build-number", "1", + "--project", "proj", "--module", "mod", + "--server-id", "sid", "--repo", "r", + "--alpine-version", "v3.20", + "--user", "admin", "--password", "pass", + "curl", + } + got := stripJFFlags(args) + assert.Equal(t, []string{"add", "curl"}, got) +} + +func TestStripJFFlags_EmptyArgs(t *testing.T) { + assert.Empty(t, stripJFFlags(nil)) + assert.Empty(t, stripJFFlags([]string{})) +} + +func TestAlpineModuleID(t *testing.T) { + assert.Equal(t, "alpine-virtual:x86_64:v3.21", + alpineModuleID("", "alpine-virtual", "x86_64", "3.21")) + assert.Equal(t, "alpine-local:aarch64:v3.20", + alpineModuleID("", "alpine-local", "aarch64", "v3.20")) + assert.Equal(t, "custom-module", + alpineModuleID("custom-module", "alpine-local", "x86_64", "v3.21")) + assert.Equal(t, "apk:unknown:unknown", + alpineModuleID("", "", "", "")) +} + +func TestApkFileNameFromID(t *testing.T) { + tests := []struct { + name string + id string + expected string + }{ + {name: "name and version", id: "curl:8.5.0-r0", expected: "curl-8.5.0-r0.apk"}, + {name: "version with revision", id: "musl:1.2.4-r2", expected: "musl-1.2.4-r2.apk"}, + {name: "only first colon replaced", id: "so:libc:1.0", expected: "so-libc:1.0.apk"}, + {name: "no colon", id: "curl", expected: "curl.apk"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, apkFileNameFromID(tc.id)) + }) + } +} + +// ─── extractPackageNames ────────────────────────────────────────────────────── + +func TestExtractPackageNames_BasicPackages(t *testing.T) { + args := []string{"curl", "git", "bash"} + assert.Equal(t, []string{"curl", "git", "bash"}, extractPackageNames(args)) +} + +func TestExtractPackageNames_SkipsFlags(t *testing.T) { + args := []string{"--no-cache", "curl", "--update-cache", "git"} + assert.Equal(t, []string{"curl", "git"}, extractPackageNames(args)) +} + +func TestExtractPackageNames_Empty(t *testing.T) { + assert.Nil(t, extractPackageNames(nil)) + assert.Nil(t, extractPackageNames([]string{"--no-cache"})) +} + +// ─── matchGlob ──────────────────────────────────────────────────────────────── + +func TestMatchGlob(t *testing.T) { + tests := []struct { + pattern string + input string + expected bool + }{ + {"*password*", "my_password_123", true}, + // matchGlob is case-sensitive; filterSecretEnvVars lowercases the name before calling it + {"*password*", "MY_PASSWORD", false}, + {"*secret*", "aws_secret_key", true}, + {"*token*", "access_token", true}, + {"*key*", "api_key", true}, + {"*password*", "username", false}, + {"exact", "exact", true}, + {"exact", "notexact", false}, + {"*", "anything", true}, + {"prefix*", "prefix_suffix", true}, + {"prefix*", "no_prefix", false}, + {"*suffix", "some_suffix", true}, + {"*suffix", "suffix_more", false}, + } + for _, tc := range tests { + t.Run(tc.pattern+"/"+tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, matchGlob(tc.pattern, tc.input)) + }) + } +} + +// ─── filterSecretEnvVars ────────────────────────────────────────────────────── + +func TestFilterSecretEnvVars_RemovesSecrets(t *testing.T) { + env := []string{ + "HOME=/root", + "MY_PASSWORD=secret123", + "ACCESS_TOKEN=tok", + "API_KEY=abc", + "SOME_SECRET=xyz", + "PATH=/usr/bin", + } + filtered := filterSecretEnvVars(env) + assert.Contains(t, filtered, "HOME=/root") + assert.Contains(t, filtered, "PATH=/usr/bin") + assert.NotContains(t, filtered, "MY_PASSWORD=secret123") + assert.NotContains(t, filtered, "ACCESS_TOKEN=tok") + assert.NotContains(t, filtered, "API_KEY=abc") + assert.NotContains(t, filtered, "SOME_SECRET=xyz") +} + +func TestFilterSecretEnvVars_PreservesNonSecrets(t *testing.T) { + env := []string{"HOME=/root", "USER=admin", "GOPATH=/go"} + filtered := filterSecretEnvVars(env) + assert.Equal(t, env, filtered) +} + +// ─── buildHTTPAuth ──────────────────────────────────────────────────────────── + +func TestBuildHTTPAuth_Standard(t *testing.T) { + auth, err := buildHTTPAuth("https://myrt.jfrog.io/artifactory", "admin", "pass123") + require.NoError(t, err) + assert.Equal(t, "basic:myrt.jfrog.io:admin:pass123", auth) +} + +func TestBuildHTTPAuth_TrailingSlash(t *testing.T) { + auth, err := buildHTTPAuth("https://rt.example.com/", "user", "pwd") + require.NoError(t, err) + assert.Equal(t, "basic:rt.example.com:user:pwd", auth) +} + +func TestBuildHTTPAuth_InvalidURL(t *testing.T) { + _, err := buildHTTPAuth("://bad-url", "user", "pwd") + assert.Error(t, err) +} + +// ─── excludeRequestedPackages ───────────────────────────────────────────────── + +func TestExcludeRequestedPackages_NilSnapshot(t *testing.T) { + result := excludeRequestedPackages(nil, []string{"curl"}) + assert.Nil(t, result, "nil snapshot should return nil") +} + +func TestExcludeRequestedPackages_NoRequestedNames(t *testing.T) { + result := excludeRequestedPackages(nil, nil) + assert.Nil(t, result) +} + +func TestExcludeRequestedPackages_RemovesRequested(t *testing.T) { + snapshot := []biUtils.AlpinePackage{ + {Name: "curl", Version: "8.5.0-r0"}, + {Name: "musl", Version: "1.2.4-r2"}, + } + result := excludeRequestedPackages(snapshot, []string{"curl"}) + require.Len(t, result, 1) + assert.Equal(t, "musl", result[0].Name) +} + +func TestExcludeRequestedPackages_IgnoresFlagLikeTokens(t *testing.T) { + snapshot := []biUtils.AlpinePackage{{Name: "my-build", Version: "1.0-r0"}} + result := excludeRequestedPackages(snapshot, []string{"curl"}) + require.Len(t, result, 1) + assert.Equal(t, "my-build", result[0].Name) +} + +func TestAqlJSONString_EscapesQuotes(t *testing.T) { + assert.Equal(t, `"repo\"key"`, aqlJSONString(`repo"key`)) + assert.Equal(t, `"curl-8.5.0-r0.apk"`, aqlJSONString("curl-8.5.0-r0.apk")) +} + +func TestEmitSignatureHint_DetectsPatterns(t *testing.T) { + emitSignatureHint("UNTRUSTED signature for APKINDEX") + emitSignatureHint("everything is fine") +} + +func TestFilterSecretEnvVars_CommaSeparatedPatterns(t *testing.T) { + t.Setenv("JFROG_CLI_ENV_EXCLUDE", "*password*,*token*") + env := []string{"HOME=/root", "DB_PASSWORD=x", "ACCESS_TOKEN=y", "PATH=/bin"} + filtered := filterSecretEnvVars(env) + assert.Contains(t, filtered, "HOME=/root") + assert.Contains(t, filtered, "PATH=/bin") + assert.NotContains(t, filtered, "DB_PASSWORD=x") + assert.NotContains(t, filtered, "ACCESS_TOKEN=y") +} + +func TestFilterSecretEnvVars_DefaultIncludesPswAndAuth(t *testing.T) { + t.Setenv("JFROG_CLI_ENV_EXCLUDE", "") + env := []string{"HOME=/root", "DB_PSW=x", "MY_AUTH=y"} + filtered := filterSecretEnvVars(env) + assert.Contains(t, filtered, "HOME=/root") + assert.NotContains(t, filtered, "DB_PSW=x") + assert.NotContains(t, filtered, "MY_AUTH=y") +} + +// ─── resolveCredentials ─────────────────────────────────────────────────────── + +func TestResolveCredentials_OverrideWins(t *testing.T) { + sd := &config.ServerDetails{} + sd.SetUser("stored-user") + sd.SetPassword("stored-pass") + + user, pass := resolveCredentials(sd, "override-user", "override-pass") + assert.Equal(t, "override-user", user) + assert.Equal(t, "override-pass", pass) +} + +func TestResolveCredentials_FallsBackToStored(t *testing.T) { + sd := &config.ServerDetails{} + sd.SetUser("stored-user") + sd.SetPassword("stored-pass") + + user, pass := resolveCredentials(sd, "", "") + assert.Equal(t, "stored-user", user) + assert.Equal(t, "stored-pass", pass) +} + +func TestResolveCredentials_NilServerDetails(t *testing.T) { + user, pass := resolveCredentials(nil, "u", "p") + assert.Equal(t, "u", user) + assert.Equal(t, "p", pass) +} + +// ─── ensureRepoExists ──────────────────────────────────────────────────────── + +func TestEnsureRepoExists_ExistingRepository(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/repositories/alpine-local", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + serverDetails := &config.ServerDetails{ArtifactoryUrl: server.URL + "/"} + require.NoError(t, ensureRepoExists("alpine-local", serverDetails)) +} + +func TestEnsureRepoExists_MissingRepository(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + serverDetails := &config.ServerDetails{ArtifactoryUrl: server.URL + "/"} + err := ensureRepoExists("missing-repo", serverDetails) + require.Error(t, err) + assert.Contains(t, err.Error(), "repository 'missing-repo' not found") +} + +func TestEnsureRepoExists_RequiresConfiguredServer(t *testing.T) { + err := ensureRepoExists("alpine-local", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no JFrog server configured") +} + +// ─── resolveHTTPAuthCredentials ─────────────────────────────────────────────── + +func TestResolveHTTPAuthCredentials_OverrideWins(t *testing.T) { + sd := &config.ServerDetails{} + sd.SetUser("stored") + sd.SetPassword("stored-pass") + + user, pass := resolveHTTPAuthCredentials(sd, "flag-user", "flag-pass") + assert.Equal(t, "flag-user", user) + assert.Equal(t, "flag-pass", pass) +} + +func TestResolveHTTPAuthCredentials_FallsBackToStored(t *testing.T) { + sd := &config.ServerDetails{} + sd.SetUser("stored-user") + sd.SetPassword("stored-pass") + + user, pass := resolveHTTPAuthCredentials(sd, "", "") + assert.Equal(t, "stored-user", user) + assert.Equal(t, "stored-pass", pass) +} + +func TestResolveHTTPAuthCredentials_NilServerDetails(t *testing.T) { + user, pass := resolveHTTPAuthCredentials(nil, "u", "p") + assert.Equal(t, "u", user) + assert.Equal(t, "p", pass) +} + +func TestBuildInfoSubcmds(t *testing.T) { + assert.True(t, buildInfoSubcmds["add"]) + assert.True(t, buildInfoSubcmds["upgrade"]) + assert.False(t, buildInfoSubcmds["upload"], "upload is handled by ApkUploadCommand, not ApkCommand") + assert.False(t, buildInfoSubcmds["update"]) + assert.False(t, buildInfoSubcmds["fetch"]) + assert.False(t, buildInfoSubcmds["search"]) + assert.False(t, buildInfoSubcmds["del"]) + assert.False(t, buildInfoSubcmds["info"]) +} + +func TestUnsupportedBuildInfoMessage(t *testing.T) { + msg := unsupportedBuildInfoMessage("update") + assert.Contains(t, msg, "apk update") + assert.Contains(t, msg, "not available") + assert.Contains(t, msg, "add") + assert.Contains(t, msg, "upgrade") + assert.Contains(t, msg, "upload") + assert.Contains(t, msg, "passthrough") +} diff --git a/artifactory/commands/alpine/apkupload.go b/artifactory/commands/alpine/apkupload.go new file mode 100644 index 00000000..0efa4bff --- /dev/null +++ b/artifactory/commands/alpine/apkupload.go @@ -0,0 +1,769 @@ +package alpine + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + bibuild "github.com/jfrog/build-info-go/build" + biUtils "github.com/jfrog/build-info-go/build/utils" + "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/gofrog/crypto" + artutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "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 ( + uploadThreads = 1 + uploadHTTPRetries = 3 + uploadHTTPRetryWaitMilliSecs = 0 +) + +var apkFilenamePattern = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\.([^.]+)\.apk$`) + +// apkFilenameNoArchPattern matches filenames produced by `apk fetch` which omit the architecture: +// e.g. "zlib-1.3.2-r0.apk" → name="zlib", version="1.3.2-r0" +var apkFilenameNoArchPattern = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\.apk$`) + +// ApkUploadCommand uploads a local .apk file to an Artifactory Alpine repository. +type ApkUploadCommand struct { + commandName string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration + filePath string + repoKey string + alpineVersion string + branch string + arch string + username string + password string +} + +// NewApkUploadCommand constructs an ApkUploadCommand for the given local file path. +func NewApkUploadCommand(filePath string) *ApkUploadCommand { + return &ApkUploadCommand{commandName: "apk-upload", filePath: filePath} +} + +// SetServerDetails sets the Artifactory server config. +func (apkCmd *ApkUploadCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApkUploadCommand { + apkCmd.serverDetails = serverDetails + return apkCmd +} + +// SetBuildConfiguration sets the build configuration. +func (apkCmd *ApkUploadCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApkUploadCommand { + apkCmd.buildConfiguration = bc + return apkCmd +} + +// SetRepo sets the Artifactory Alpine repository key. +func (apkCmd *ApkUploadCommand) SetRepo(repoKey string) *ApkUploadCommand { + apkCmd.repoKey = repoKey + return apkCmd +} + +// SetAlpineVersion sets the Alpine release tag (e.g. "v3.20"). +func (apkCmd *ApkUploadCommand) SetAlpineVersion(version string) *ApkUploadCommand { + apkCmd.alpineVersion = version + return apkCmd +} + +// SetBranch sets the Alpine repository branch (main, community, edge). +func (apkCmd *ApkUploadCommand) SetBranch(branch string) *ApkUploadCommand { + apkCmd.branch = branch + return apkCmd +} + +// SetArch overrides the architecture parsed from the filename. +func (apkCmd *ApkUploadCommand) SetArch(arch string) *ApkUploadCommand { + apkCmd.arch = arch + return apkCmd +} + +// SetUsername sets the username CLI flag override. +func (apkCmd *ApkUploadCommand) SetUsername(username string) *ApkUploadCommand { + apkCmd.username = username + return apkCmd +} + +// SetPassword sets the password CLI flag override. +func (apkCmd *ApkUploadCommand) SetPassword(password string) *ApkUploadCommand { + apkCmd.password = password + return apkCmd +} + +// CommandName satisfies the Command interface. +func (apkCmd *ApkUploadCommand) CommandName() string { return apkCmd.commandName } + +// ServerDetails satisfies the Command interface. +func (apkCmd *ApkUploadCommand) ServerDetails() (*config.ServerDetails, error) { + return apkCmd.serverDetails, nil +} + +// Run uploads the .apk file, sets artifact properties, and optionally records Build Info. +func (apkCmd *ApkUploadCommand) Run() error { + // branch defaults to main when not provided. + if apkCmd.branch == "" { + apkCmd.branch = "main" + } + + // --alpine-version is required — it defines part of the upload path + // (////) and cannot be inferred + // from the filename (official Alpine .apk files carry no version in their name). + // Resolve alpine version: --alpine-version flag > the running host's /etc/alpine-release. + // The .apk carries no target Alpine version (neither the filename nor .PKGINFO record it), + // so when the flag is omitted we fall back to the host release — with a warning, since the + // host version may differ from the package's intended target (e.g. cross-release builds). + if apkCmd.alpineVersion == "" { + if sysVer := detectSystemAlpineVersion(); sysVer != "" { + apkCmd.alpineVersion = sysVer + log.Warn(fmt.Sprintf("No --alpine-version provided; falling back to the host's Alpine version %q from /etc/alpine-release. "+ + "Pass --alpine-version to be explicit — the host version may differ from the package's target.", sysVer)) + } + } + if apkCmd.alpineVersion == "" { + return errorutils.CheckErrorf("--alpine-version is required (e.g. --alpine-version v3.21) — could not auto-detect it from /etc/alpine-release") + } + // Normalize to Alpine's canonical "v"-prefixed form (e.g. "3.21" -> "v3.21") so uploads land + // under the same segment that `jf setup apk` and native apk use. Without this, an + // upload with "3.21" would sit at /3.21/... while apk reads from /v3.21/.... + if !strings.HasPrefix(apkCmd.alpineVersion, "v") { + apkCmd.alpineVersion = "v" + apkCmd.alpineVersion + } + + // Resolve arch with priority: --arch flag > embedded .PKGINFO arch > system arch. + // The .PKGINFO value is the package's true target arch (correct even for cross-arch + // uploads); the system arch is a last-resort fallback and may be wrong for cross-arch + // or noarch packages, so we warn when using it. + if apkCmd.arch == "" { + if arch, archErr := archFromEmbeddedPkgInfo(apkCmd.filePath); archErr == nil && arch != "" { + apkCmd.arch = arch + log.Info(fmt.Sprintf("No --arch provided, detected arch %q from the package's .PKGINFO.", arch)) + } else if sysArch := detectSystemArch(); sysArch != "" { + apkCmd.arch = sysArch + log.Warn(fmt.Sprintf("No --arch provided and none found in .PKGINFO; falling back to system arch %q. "+ + "Pass --arch to be explicit — the system arch may be wrong for cross-arch or noarch packages.", sysArch)) + } + } + if apkCmd.arch == "" { + return errorutils.CheckErrorf("--arch is required (e.g. --arch x86_64) — could not auto-detect it from the package's .PKGINFO or the system") + } + if err := validateArtifactoryPathSegment("alpine-version", apkCmd.alpineVersion); err != nil { + return err + } + if err := validateArtifactoryPathSegment("branch", apkCmd.branch); err != nil { + return err + } + if err := validateArtifactoryPathSegment("arch", apkCmd.arch); err != nil { + return err + } + + filename := filepath.Base(apkCmd.filePath) + pkgName, pkgVersion, err := parseApkFilename(filename) + if err != nil { + return err + } + + if apkCmd.serverDetails == nil { + return errorutils.CheckErrorf("no JFrog server configured — run 'jf c add' first or pass --server-id") + } + if apkCmd.username != "" { + apkCmd.serverDetails.SetUser(apkCmd.username) + } + if apkCmd.password != "" { + apkCmd.serverDetails.SetPassword(apkCmd.password) + } + + rtURL := apkCmd.serverDetails.GetArtifactoryUrl() + + // Resolve repo key with priority: --repo flag > /etc/apk/repositories + repoFromFlag := apkCmd.repoKey != "" + if apkCmd.repoKey == "" { + apkCmd.repoKey = resolveRepoFromRepositoriesFile(rtURL) + if apkCmd.repoKey != "" { + log.Info(fmt.Sprintf("No --repo provided, resolved repo from /etc/apk/repositories: %s", apkCmd.repoKey)) + } + } + if apkCmd.repoKey == "" { + return errorutils.CheckErrorf("--repo is required for upload (or run 'jf setup apk' to configure /etc/apk/repositories first)") + } + + // An explicit --repo is hard user intent: fail fast if Artifactory does not have it, + if repoFromFlag { + if err := ensureRepoExists(apkCmd.repoKey, apkCmd.serverDetails); err != nil { + return err + } + } + + // If the resolved repo is virtual, resolve it to its default deployment (local) repo. + apkCmd.repoKey, err = resolveLocalUploadRepo(apkCmd.repoKey, apkCmd.serverDetails) + if err != nil { + return err + } + + // Artifactory only indexes Alpine packages deployed under + // ////. Omitting the branch segment + // leaves the package outside every APKINDEX, so it is always included here. + target := fmt.Sprintf("%s/%s/%s/%s/%s", apkCmd.repoKey, apkCmd.alpineVersion, apkCmd.branch, apkCmd.arch, filename) + + collectBuildInfo, err := apkCmd.buildConfiguration.IsCollectBuildInfo() + if err != nil { + return err + } + + log.Info(fmt.Sprintf("Uploading %s → %s", filename, target)) + if err := apkCmd.uploadWithArtifactory(target, pkgName, pkgVersion, collectBuildInfo); err != nil { + return err + } + log.Info("Upload successful.") + + if collectBuildInfo { + fileDetails, detailsErr := crypto.GetFileDetails(apkCmd.filePath, true) + if detailsErr != nil { + log.Warn("Build Info artifact recording failed: could not compute local checksums:", detailsErr) + } else if err := apkCmd.recordBuildInfoArtifact(filename, pkgName, pkgVersion, apkCmd.arch, fileDetails.Checksum); err != nil { + log.Warn("Build Info artifact recording failed:", err) + } + } + return nil +} + +func (apkCmd *ApkUploadCommand) uploadWithArtifactory(target, pkgName, pkgVersion string, collectBuildInfo bool) error { + servicesManager, err := artutils.CreateUploadServiceManager(apkCmd.serverDetails, uploadThreads, + uploadHTTPRetries, uploadHTTPRetryWaitMilliSecs, false, nil) + if err != nil { + return errorutils.CheckErrorf("failed to create Artifactory upload service: %w", err) + } + + up := services.NewUploadParams() + up.Pattern = apkCmd.filePath + up.Target = target + up.Flat = true + + alpineProps := fmt.Sprintf( + "os.name=alpine;os.version=%s;os.arch=%s;apk.name=%s;apk.version=%s", + apkCmd.alpineVersion, apkCmd.arch, pkgName, pkgVersion, + ) + up.TargetProps, err = specutils.ParseProperties(alpineProps) + if err != nil { + return errorutils.CheckErrorf("failed to parse Alpine artifact properties: %w", err) + } + + if collectBuildInfo { + up.BuildProps, err = buildUtils.CreateBuildPropsFromConfiguration(apkCmd.buildConfiguration) + if err != nil { + return errorutils.CheckErrorf("failed to create build properties: %w", err) + } + summary, uploadErr := servicesManager.UploadFilesWithSummary(artifactory.UploadServiceOptions{}, up) + if uploadErr != nil { + return errorutils.CheckErrorf("failed to upload %s: %w", filepath.Base(apkCmd.filePath), uploadErr) + } + defer closeUploadSummaryReaders(summary) + if summary.TotalFailed > 0 { + return errorutils.CheckErrorf("failed to upload the Alpine package to Artifactory. See Artifactory logs for more details.") + } + if summary.TotalSucceeded < 1 { + return errorutils.CheckErrorf("upload finished with 0 succeeded files for %s", filepath.Base(apkCmd.filePath)) + } + return nil + } + + totalUploaded, totalFailed, err := servicesManager.UploadFiles(artifactory.UploadServiceOptions{}, up) + if err != nil { + return errorutils.CheckErrorf("failed to upload %s: %w", filepath.Base(apkCmd.filePath), err) + } + if totalFailed > 0 { + return errorutils.CheckErrorf("failed to upload the Alpine package to Artifactory. See Artifactory logs for more details.") + } + if totalUploaded < 1 { + return errorutils.CheckErrorf("upload finished with 0 succeeded files for %s", filepath.Base(apkCmd.filePath)) + } + return nil +} + +func closeUploadSummaryReaders(summary *specutils.OperationSummary) { + if summary == nil { + return + } + if summary.TransferDetailsReader != nil { + _ = summary.TransferDetailsReader.Close() + } + if summary.ArtifactsDetailsReader != nil { + _ = summary.ArtifactsDetailsReader.Close() + } +} + +// recordBuildInfoArtifact saves the uploaded artifact (and its dependencies) to the local Build Info cache. +func (apkCmd *ApkUploadCommand) recordBuildInfoArtifact(filename, pkgName, pkgVersion, arch string, checksum crypto.Checksum) error { + buildObj, err := buildUtils.PrepareBuildPrerequisites(apkCmd.buildConfiguration) + if err != nil { + return err + } + + moduleID := alpineModuleID(apkCmd.buildConfiguration.GetModule(), apkCmd.repoKey, arch, apkCmd.alpineVersion) + + // Build the Artifactory path to use as requestedBy — matches the artifact path + // recorded in the same module (including the segment) so reviewers can + // trace dep → artifact directly. + artifactoryPath := fmt.Sprintf("%s/%s/%s/%s/%s", apkCmd.repoKey, apkCmd.alpineVersion, apkCmd.branch, arch, filename) + deps, err := apkCmd.collectApkDependencies(pkgName, apkCmd.filePath, artifactoryPath) + if err != nil { + log.Warn("Failed to collect APK dependencies for build info:", err) + } + + module := entities.Module{ + Id: moduleID, + Type: entities.Apk, + Artifacts: []entities.Artifact{{ + Name: fmt.Sprintf("%s:%s:%s", pkgName, pkgVersion, arch), + Path: fmt.Sprintf("%s/%s/%s/%s/%s", apkCmd.repoKey, apkCmd.alpineVersion, apkCmd.branch, arch, filename), + Checksum: entities.Checksum{ + Sha1: checksum.Sha1, + Sha256: checksum.Sha256, + Md5: checksum.Md5, + }, + }}, + Dependencies: deps, + } + buildInfo := &entities.BuildInfo{Modules: []entities.Module{module}} + return buildObj.SaveBuildInfo(buildInfo) +} + +// parseApkFilename extracts name and version from an Alpine package filename. +// Both official Alpine naming (--.apk, no arch in filename) +// and the extended form (--..apk) are accepted. +// Arch is always sourced from the --arch flag or auto-detection, never from the filename. +func parseApkFilename(filename string) (name, version string, err error) { + if m := apkFilenamePattern.FindStringSubmatch(filename); m != nil { + return m[1], m[2], nil + } + if m := apkFilenameNoArchPattern.FindStringSubmatch(filename); m != nil { + return m[1], m[2], nil + } + return "", "", errorutils.CheckErrorf( + "cannot parse Alpine package filename %q — expected --.apk or --..apk", + filename, + ) +} + +// ensureRepoExists verifies that repoKey exists in Artifactory. +// Used when the user explicitly passed --repo so we fail before attempting upload. +func ensureRepoExists(repoKey string, serverDetails *config.ServerDetails) error { + if serverDetails == nil { + return errorutils.CheckErrorf( + "cannot validate --repo '%s': no JFrog server configured — run 'jf c add' or pass --server-id", + repoKey, + ) + } + servicesManager, err := artutils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + return errorutils.CheckErrorf("failed to create Artifactory service manager to validate --repo '%s': %w", repoKey, err) + } + exists, err := servicesManager.IsRepoExists(repoKey) + if err != nil { + return errorutils.CheckErrorf("failed to validate --repo '%s': %w", repoKey, err) + } + if !exists { + return errorutils.CheckErrorf("repository '%s' not found — check --repo or create the repository in Artifactory", repoKey) + } + return nil +} + +// resolveLocalUploadRepo ensures the target repo for upload is a local repository. +// If repoKey is a virtual repository, it resolves to its DefaultDeploymentRepo. +// Returns an error if the virtual repo has no default deployment repo configured. +func resolveLocalUploadRepo(repoKey string, serverDetails *config.ServerDetails) (string, error) { + servicesManager, err := artutils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + log.Debug("Could not create services manager for repo type check, using repo as-is:", err) + return repoKey, nil + } + repoDetails := &services.VirtualRepositoryBaseParams{} + if err = servicesManager.GetRepository(repoKey, repoDetails); err != nil { + log.Debug(fmt.Sprintf("Could not determine type for repo '%s', using as-is: %s", repoKey, err)) + return repoKey, nil + } + if repoDetails.Rclass == services.VirtualRepositoryRepoType { + if repoDetails.DefaultDeploymentRepo == "" { + return "", errorutils.CheckErrorf( + "virtual repository '%s' has no default deployment repository configured — "+ + "set one in Artifactory UI or pass a local repository with --repo", repoKey) + } + log.Info(fmt.Sprintf("Resolved virtual repository '%s' → local repository '%s'.", repoKey, repoDetails.DefaultDeploymentRepo)) + return repoDetails.DefaultDeploymentRepo, nil + } + return repoKey, nil +} + +// resolveRepoFromRepositoriesFile reads /etc/apk/repositories and returns the +// repo key of the first JFrog Artifactory entry whose URL contains rtURL. +func resolveRepoFromRepositoriesFile(rtURL string) string { + data, err := os.ReadFile("/etc/apk/repositories") + if err != nil { + return "" + } + rtHost := strings.TrimRight(rtURL, "/") + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.HasPrefix(line, rtHost) { + continue + } + // URL format: https://rt.example.com/artifactory//vX.YY/main + rest := strings.TrimPrefix(line, rtHost+"/") + // strip leading "artifactory/" if present + rest = strings.TrimPrefix(rest, "artifactory/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + } + return "" +} + +func (apkCmd *ApkUploadCommand) collectApkDependencies(pkgName, filePath, uploadedPkgID string) ([]entities.Dependency, error) { + specs, err := depsFromApkInfoCommand(pkgName) + if err != nil || len(specs) == 0 { + log.Debug("apk info -a not available for", pkgName, "— falling back to .PKGINFO parsing:", err) + specs, err = depsFromEmbeddedPkgInfo(filePath) + if err != nil { + return nil, err + } + } + + providers, installedByName := apkInstalledProviderIndex() + + deps := make([]entities.Dependency, 0, len(specs)) + seen := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + id := resolveDepIDWithProviders(spec, providers, installedByName) + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + var requestedBy [][]string + if uploadedPkgID != "" { + requestedBy = [][]string{{uploadedPkgID}} + } + deps = append(deps, entities.Dependency{ + Id: id, + Scopes: []string{bibuild.AlpineScopeProd}, + RequestedBy: bibuild.FlattenRequestedBy(requestedBy), + }) + } + + deps = enrichDepsFromLocalCache(deps) + if apkCmd.serverDetails != nil && apkCmd.repoKey != "" { + deps = apkCmd.enrichUploadDepsFromAQL(deps) + } + return deps, nil +} + +func (apkCmd *ApkUploadCommand) enrichUploadDepsFromAQL(deps []entities.Dependency) []entities.Dependency { + var missing []int + for i, dep := range deps { + if dep.Sha256 == "" && dep.Sha1 == "" { + missing = append(missing, i) + } + } + if len(missing) == 0 { + return deps + } + sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, -1, 0, false) + if err != nil { + log.Debug("Could not create Artifactory service manager for upload AQL enrichment:", err) + return deps + } + return enrichDepsChecksumsFromAQL(deps, missing, sm, apkCmd.repoKey) +} + +func enrichDepsFromLocalCache(deps []entities.Dependency) []entities.Dependency { + cacheDirs := []string{} + if envCache := os.Getenv("APKCACHE"); envCache != "" { + cacheDirs = append(cacheDirs, envCache) + } + cacheDirs = append(cacheDirs, "/var/cache/apk") + + for i, dep := range deps { + if dep.Sha1 != "" || dep.Sha256 != "" { + continue + } + pkg := alpinePackageFromDepID(dep.Id) + for _, dir := range cacheDirs { + checksums, err := biUtils.ChecksumsFromCache(pkg, dir) + if err != nil || len(checksums) == 0 { + continue + } + deps[i].Sha1 = checksums[crypto.SHA1] + deps[i].Sha256 = checksums[crypto.SHA256] + deps[i].Md5 = checksums[crypto.MD5] + break + } + } + return deps +} + +func alpinePackageFromDepID(id string) biUtils.AlpinePackage { + if isApkProviderToken(id) { + return biUtils.AlpinePackage{Name: id} + } + if name, ver, ok := strings.Cut(id, ":"); ok && ver != "" { + return biUtils.AlpinePackage{Name: name, Version: ver} + } + if m := apkFilenameNoArchPattern.FindStringSubmatch(id + ".apk"); m != nil { + return biUtils.AlpinePackage{Name: m[1], Version: m[2]} + } + return biUtils.AlpinePackage{Name: id} +} + +func isApkProviderToken(id string) bool { + for _, prefix := range []string{"so:", "cmd:", "pc:"} { + if strings.HasPrefix(id, prefix) { + return true + } + } + return false +} + +func validateArtifactoryPathSegment(flag, value string) error { + if value == "" { + return errorutils.CheckErrorf("%s must not be empty", flag) + } + if strings.ContainsAny(value, `/\`) || value == "." || value == ".." || strings.Contains(value, "..") { + return errorutils.CheckErrorf("invalid %s %q: must not contain path separators or '..'", flag, value) + } + return nil +} + +// depsFromApkInfoCommand runs `apk info -a ` and parses the "depends on:" section. +// This works only when the package is installed on the local system. +func depsFromApkInfoCommand(pkgName string) ([]string, error) { + out, err := exec.Command("apk", "info", "-a", pkgName).Output() + if err != nil { + return nil, fmt.Errorf("apk info -a %q: %w", pkgName, err) + } + return parseDependsSection(string(out)), nil +} + +// parseDependsSection extracts dependency specs from the "depends on:" block +// produced by `apk info -a`. +// +// Example block: +// +// zlib-1.3.1-r2 depends on: +// so:libc.musl-x86_64.so.1 +// +func parseDependsSection(output string) []string { + var specs []string + inSection := false + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, " depends on:") { + inSection = true + continue + } + if inSection { + if trimmed == "" { + break + } + specs = append(specs, trimmed) + } + } + return specs +} + +// depsFromEmbeddedPkgInfo opens the .apk archive and reads `depend = ...` lines +// from the .PKGINFO metadata file embedded in its first tar+gzip stream. +func depsFromEmbeddedPkgInfo(filePath string) ([]string, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open %q: %w", filePath, err) + } + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + if err != nil { + return nil, fmt.Errorf("gzip reader for %q: %w", filePath, err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar from %q: %w", filePath, err) + } + if hdr.Name != ".PKGINFO" { + continue + } + return parsePkgInfoDepends(tr), nil + } + return nil, nil +} + +// parsePkgInfoDepends reads `depend = ` lines from a .PKGINFO stream. +func parsePkgInfoDepends(r io.Reader) []string { + var specs []string + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + after, found := strings.CutPrefix(line, "depend = ") + if found { + specs = append(specs, strings.TrimSpace(after)) + } + } + if err := scanner.Err(); err != nil { + log.Debug("Failed reading .PKGINFO depend lines:", err) + } + return specs +} + +// archFromEmbeddedPkgInfo opens the .apk archive and returns the value of the `arch = ` +// line from the embedded .PKGINFO metadata — the package's true target architecture. +// Returns an empty string (and nil error) when the field is absent. +func archFromEmbeddedPkgInfo(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("open %q: %w", filePath, err) + } + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("gzip reader for %q: %w", filePath, err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("reading tar from %q: %w", filePath, err) + } + if hdr.Name != ".PKGINFO" { + continue + } + return parsePkgInfoArch(tr), nil + } + return "", nil +} + +// parsePkgInfoArch reads the `arch = ` line from a .PKGINFO stream. +func parsePkgInfoArch(r io.Reader) string { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + if after, found := strings.CutPrefix(scanner.Text(), "arch = "); found { + return strings.TrimSpace(after) + } + } + if err := scanner.Err(); err != nil { + log.Debug("Failed reading the .PKGINFO arch line:", err) + } + return "" +} + +// detectSystemArch returns the Alpine architecture of the current system via +// `apk --print-arch`, which yields Alpine naming (e.g. x86_64, aarch64). +// Returns an empty string when apk is unavailable or the command fails. +func detectSystemArch() string { + out, err := exec.Command("apk", "--print-arch").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// detectSystemAlpineVersion reads /etc/alpine-release and returns the major.minor version +// (e.g. "3.21", normalized later to "v3.21"). Returns "" when the file is absent or +// unparseable (i.e. not running on Alpine). +func detectSystemAlpineVersion() string { + data, err := os.ReadFile("/etc/alpine-release") + if err != nil { + return "" + } + ver := strings.TrimSpace(string(data)) + ver = strings.TrimPrefix(ver, "v") + parts := strings.Split(ver, ".") + if len(parts) < 2 { + return "" + } + return parts[0] + "." + parts[1] +} + +func resolveDepID(spec string) (string, error) { + name := stripVersionConstraint(spec) + + out, err := exec.Command("apk", "info", name).Output() + if err != nil { + return "", fmt.Errorf("apk info %q: %w", name, err) + } + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + return strings.TrimSuffix(line, " description:"), nil + } + } + return "", fmt.Errorf("empty output from apk info %q", name) +} + +func apkInstalledProviderIndex() (providers map[string]string, byName map[string]biUtils.AlpinePackage) { + pkgs, err := biUtils.ListInstalledPackages() + if err != nil { + log.Debug("Could not read the Alpine installed package database:", err) + return nil, nil + } + byName = make(map[string]biUtils.AlpinePackage, len(pkgs)) + for _, pkg := range pkgs { + byName[pkg.Name] = pkg + } + return biUtils.BuildProviderIndex(pkgs), byName +} + +func resolveDepIDWithProviders(spec string, providers map[string]string, byName map[string]biUtils.AlpinePackage) string { + token := stripVersionConstraint(spec) + if name := biUtils.ResolveDependencyProvider(token, providers); name != "" { + if pkg, ok := byName[name]; ok && pkg.Version != "" { + return pkg.ID() + } + return name + } + id, err := resolveDepID(spec) + if err != nil { + log.Debug("Could not resolve APK dependency", spec, "-", err) + return token + } + return id +} + +// stripVersionConstraint removes trailing version constraints (>=, ~=, =, !=) from a dep spec, +// leaving just the package or provider name. +func stripVersionConstraint(spec string) string { + for _, op := range []string{">=", "<=", "~=", "!=", ">", "<", "="} { + if idx := strings.Index(spec, op); idx != -1 { + return strings.TrimSpace(spec[:idx]) + } + } + return strings.TrimSpace(spec) +} diff --git a/artifactory/commands/alpine/apkupload_test.go b/artifactory/commands/alpine/apkupload_test.go new file mode 100644 index 00000000..e3e06ff8 --- /dev/null +++ b/artifactory/commands/alpine/apkupload_test.go @@ -0,0 +1,167 @@ +package alpine + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "os" + "path/filepath" + "testing" + + bibuild "github.com/jfrog/build-info-go/build" + biUtils "github.com/jfrog/build-info-go/build/utils" + specutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateArtifactoryPathSegment(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "valid branch", value: "main", wantErr: false}, + {name: "valid arch", value: "x86_64", wantErr: false}, + {name: "valid version", value: "v3.21", wantErr: false}, + {name: "empty", value: "", wantErr: true}, + {name: "slash", value: "main/../other", wantErr: true}, + {name: "backslash", value: `main\other`, wantErr: true}, + {name: "dotdot", value: "..", wantErr: true}, + {name: "contains dotdot", value: "foo..bar", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateArtifactoryPathSegment("branch", tc.value) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestAlpinePackageFromDepID(t *testing.T) { + pkg := alpinePackageFromDepID("curl:8.5.0-r0") + assert.Equal(t, "curl", pkg.Name) + assert.Equal(t, "8.5.0-r0", pkg.Version) + + pkg = alpinePackageFromDepID("musl-1.2.4-r2") + assert.Equal(t, "musl", pkg.Name) + assert.Equal(t, "1.2.4-r2", pkg.Version) +} + +func TestAlpinePackageFromDepID_ProviderTokenIsNotSplit(t *testing.T) { + pkg := alpinePackageFromDepID("so:libc.musl-x86_64.so.1") + assert.Equal(t, "so:libc.musl-x86_64.so.1", pkg.Name) + assert.Empty(t, pkg.Version) + + pkg = alpinePackageFromDepID("cmd:curl") + assert.Equal(t, "cmd:curl", pkg.Name) + assert.Empty(t, pkg.Version) +} + +func TestIsApkProviderToken(t *testing.T) { + assert.True(t, isApkProviderToken("so:libz.so.1")) + assert.True(t, isApkProviderToken("cmd:curl")) + assert.True(t, isApkProviderToken("pc:openssl")) + assert.False(t, isApkProviderToken("curl:8.5.0-r0")) + assert.False(t, isApkProviderToken("musl")) +} + +func TestResolveDepIDWithProviders(t *testing.T) { + providers := map[string]string{ + "so:libcurl.so.4": "libcurl", + "cmd:curl": "curl", + "musl": "musl", + } + byName := map[string]biUtils.AlpinePackage{ + "libcurl": {Name: "libcurl", Version: "8.5.0-r0"}, + "curl": {Name: "curl", Version: "8.5.0-r0"}, + "musl": {Name: "musl", Version: "1.2.4-r2"}, + } + + assert.Equal(t, "libcurl:8.5.0-r0", resolveDepIDWithProviders("so:libcurl.so.4", providers, byName), + "a shared-object dep must resolve to the package providing it") + assert.Equal(t, "libcurl:8.5.0-r0", resolveDepIDWithProviders("so:libcurl.so.4=8.5.0", providers, byName), + "the version constraint must be stripped before provider lookup") + assert.Equal(t, "curl:8.5.0-r0", resolveDepIDWithProviders("cmd:curl", providers, byName)) + assert.Equal(t, "musl:1.2.4-r2", resolveDepIDWithProviders("musl>=1.2.3", providers, byName)) +} + +func TestResolveDepIDWithProviders_UnknownProviderFallsBackToToken(t *testing.T) { + assert.Equal(t, "so:libunknown.so.9", resolveDepIDWithProviders("so:libunknown.so.9>=1", nil, nil)) +} + +func TestStripVersionConstraint(t *testing.T) { + assert.Equal(t, "musl", stripVersionConstraint("musl>=1.2.3")) + assert.Equal(t, "openssl", stripVersionConstraint("openssl<=3.0")) + assert.Equal(t, "bash", stripVersionConstraint("bash")) + assert.Equal(t, "so:libz.so.1", stripVersionConstraint("so:libz.so.1>=1.0"), + "provider prefixes must survive so the token can be matched against provides") +} + +func TestAlpineUploadTargetProps(t *testing.T) { + propsStr := fmt.Sprintf( + "os.name=alpine;os.version=%s;os.arch=%s;apk.name=%s;apk.version=%s", + "v3.21", "x86_64", "curl", "8.5.0-r0", + ) + props, err := specutils.ParseProperties(propsStr) + require.NoError(t, err) + assert.Equal(t, "alpine", props.ToMap()["os.name"][0]) + assert.Equal(t, "v3.21", props.ToMap()["os.version"][0]) + assert.Equal(t, "x86_64", props.ToMap()["os.arch"][0]) + assert.Equal(t, "curl", props.ToMap()["apk.name"][0]) + assert.Equal(t, "8.5.0-r0", props.ToMap()["apk.version"][0]) +} + +func TestResolveDepID_MissingApk(t *testing.T) { + _, err := resolveDepID("this-package-definitely-does-not-exist-xyz") + require.Error(t, err) +} + +func writeTestApk(t *testing.T, pkgInfo string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "testpkg-1.0.0-r0.x86_64.apk") + f, err := os.Create(path) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: ".PKGINFO", + Mode: 0644, + Size: int64(len(pkgInfo)), + })) + _, err = tw.Write([]byte(pkgInfo)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return path +} + +func TestCollectApkDependencies_ScopesMatchAddFlow(t *testing.T) { + pkgInfo := "pkgname = testpkg\npkgver = 1.0.0-r0\narch = x86_64\n" + + "depend = musl>=1.2.3\ndepend = zlib\n" + apkPath := writeTestApk(t, pkgInfo) + + apkCmd := NewApkUploadCommand(apkPath) + uploadedID := "alpine-local/v3.21/main/x86_64/testpkg-1.0.0-r0.x86_64.apk" + deps, err := apkCmd.collectApkDependencies("testpkg-does-not-exist-xyz", apkPath, uploadedID) + require.NoError(t, err) + require.NotEmpty(t, deps, "dependencies should be parsed from .PKGINFO") + + for _, dep := range deps { + assert.Equal(t, []string{bibuild.AlpineScopeProd}, dep.Scopes, + "dependency %s should be scoped %q like first-level deps in the add flow", dep.Id, bibuild.AlpineScopeProd) + require.NotEmpty(t, dep.RequestedBy, "dependency %s should record the uploaded artifact as its parent", dep.Id) + assert.Equal(t, uploadedID, dep.RequestedBy[0][len(dep.RequestedBy[0])-1]) + } +} + +func TestAlpineScopeConstantsAreStable(t *testing.T) { + assert.Equal(t, "prod", bibuild.AlpineScopeProd) + assert.Equal(t, "transitive", bibuild.AlpineScopeTransitive) +} diff --git a/artifactory/commands/alpine/credentials.go b/artifactory/commands/alpine/credentials.go new file mode 100644 index 00000000..115baa11 --- /dev/null +++ b/artifactory/commands/alpine/credentials.go @@ -0,0 +1,60 @@ +package alpine + +import ( + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/auth" +) + +// resolveCredentials resolves the effective username/password for direct Artifactory REST calls, +// preferring access token over stored password. +func resolveCredentials(serverDetails *config.ServerDetails, usernameOverride, passwordOverride string) (username, password string) { + username = usernameOverride + password = passwordOverride + + if username == "" && serverDetails != nil { + username = serverDetails.GetUser() + } + + if serverDetails != nil { + if token := serverDetails.GetAccessToken(); token != "" { + if username == "" { + username = auth.ExtractUsernameFromAccessToken(token) + } + if password == "" { + password = token + } + } else if password == "" { + password = serverDetails.GetPassword() + } + } + return +} + +// resolveHTTPAuthCredentials resolves credentials for the HTTP_AUTH subprocess env var, +// preferring stored password over token to avoid short-lived token expiry mid-run. +func resolveHTTPAuthCredentials(serverDetails *config.ServerDetails, usernameOverride, passwordOverride string) (username, password string) { + username = usernameOverride + password = passwordOverride + + if serverDetails == nil { + return + } + + if username == "" { + username = serverDetails.GetUser() + } + + if password == "" { + password = serverDetails.GetPassword() + } + + if password == "" { + if token := serverDetails.GetAccessToken(); token != "" { + if username == "" { + username = auth.ExtractUsernameFromAccessToken(token) + } + password = token + } + } + return +} diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index f83c64fb..a470d5b7 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -2,8 +2,10 @@ package setup import ( _ "embed" + "encoding/json" "fmt" "io" + "net/http" "net/url" "os" "os/exec" @@ -101,6 +103,7 @@ var packageManagerConfigs = map[project.ProjectType]packageManagerConfig{ project.Podman: {location: "your Podman credential store", credentialsOnly: true}, project.Helm: {location: "your Helm registry credential store", credentialsOnly: true}, project.Apt: {location: "your apt configuration"}, + project.Apk: {location: "your apk configuration"}, } // configScopeNote describes what the command changed and how widely it applies, or @@ -155,6 +158,8 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{ project.Gradle: repository.Gradle, project.Maven: repository.Maven, + + project.Apk: repository.Alpine, } // SetupCommand configures registries and authentication for various package manager (npm, Yarn, Pip, Pipenv, Poetry, UV, Go) @@ -245,7 +250,8 @@ func (sc *SetupCommand) Run() (err error) { // If the repository name is not provided, and the package manager is not Docker or Podman, prompt the user to select a repository. // Docker and Podman do not require a repository name as they authenticate directly with the platform and require the repository name as part of the image name. - if sc.repoName == "" && sc.packageManager != project.Docker && sc.packageManager != project.Podman { + // Alpine (Apk) handles its own repo-type-first interactive flow inside configureApk(). + if sc.repoName == "" && sc.packageManager != project.Docker && sc.packageManager != project.Podman && sc.packageManager != project.Apk { // Prompt the user to select a virtual repository that matches the package manager. if err = sc.promptUserToSelectRepository(); err != nil { return err @@ -280,6 +286,8 @@ func (sc *SetupCommand) Run() (err error) { err = sc.configureUV() case project.Apt: err = sc.configureApt() + case project.Apk: + err = sc.configureApk() default: err = errorutils.CheckErrorf("unsupported package manager: %s", sc.packageManager) } @@ -299,17 +307,26 @@ func (sc *SetupCommand) Run() (err error) { // promptUserToSelectRepository prompts the user to select a compatible virtual repository. func (sc *SetupCommand) promptUserToSelectRepository() (err error) { + return sc.promptUserToSelectRepositoryFiltered(utils.Virtual.String()) +} + +func (sc *SetupCommand) promptUserToSelectRepositoryFiltered(repoType string) (err error) { repoFilterParams := services.RepositoriesFilterParams{ - RepoType: utils.Virtual.String(), + RepoType: repoType, PackageType: packageManagerToRepositoryPackageType[sc.packageManager], ProjectKey: sc.projectKey, } + promptMessage := fmt.Sprintf("To configure %s, we need you to select a %s repository:", repoFilterParams.PackageType, repoFilterParams.RepoType) + if repoType == "" { + promptMessage = fmt.Sprintf("To configure %s, we need you to select a repository:", repoFilterParams.PackageType) + } + // Prompt for repository selection based on filter parameters. sc.repoName, err = utils.SelectRepositoryInteractively( sc.serverDetails, repoFilterParams, - fmt.Sprintf("To configure %s, we need you to select a %s repository:", repoFilterParams.PackageType, repoFilterParams.RepoType)) + promptMessage) return err } @@ -905,3 +922,395 @@ func (sc *SetupCommand) configureApt() error { } return cmd.Run() } + +// ── Alpine (APK) ───────────────────────────────────────────────────────────── + +const ( + apkKeysDir = "/etc/apk/keys" + apkRepositoriesFile = "/etc/apk/repositories" + alpineReleaseFile = "/etc/alpine-release" + apkDefaultBranch = "main" +) + +// configureApk sets up APK to use an Artifactory Alpine repository. +func (sc *SetupCommand) configureApk() error { + if sc.repoName == "" { + repoType, err := sc.resolveApkRepoType() + if err != nil { + return err + } + if err = sc.promptUserToSelectRepositoryFiltered(repoType); err != nil { + return err + } + } else if err := apkValidateRepositoryExists(sc.serverDetails.GetArtifactoryUrl(), sc.repoName, sc.serverDetails); err != nil { + return err + } + + rtURL := strings.TrimRight(sc.serverDetails.GetArtifactoryUrl(), "/") + + alpineVersion := detectAlpineVersion() + + var repoURL string + if alpineVersion != "" { + repoURL = fmt.Sprintf("%s/%s/%s/%s/", rtURL, sc.repoName, alpineVersion, apkDefaultBranch) + } else { + repoURL = fmt.Sprintf("%s/%s/", rtURL, sc.repoName) + } + + username, password := apkResolveCredentials(sc.serverDetails) + repoURLWithCreds, err := apkEmbedCredentials(repoURL, username, password) + if err != nil { + return err + } + + if err := apkWriteSigningKey(rtURL, sc.repoName, sc.serverDetails); err != nil { + log.Warn(fmt.Sprintf("Could not fetch RSA signing key for repo %q: %v\n"+ + "APK will not be able to verify package signatures. "+ + "Configure a key pair on the repository in Artifactory to fix this.", sc.repoName, err)) + } + + return apkUpdateRepositories(repoURLWithCreds) +} + +func apkValidateRepositoryExists(rtURL, repoKey string, serverDetails *config.ServerDetails) error { + endpoint := fmt.Sprintf("%s/api/repositories/%s", strings.TrimRight(rtURL, "/"), repoKey) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return errorutils.CheckErrorf("failed to validate --repo %q: %w", repoKey, err) + } + apkSetAuth(req, serverDetails) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errorutils.CheckErrorf("failed to validate --repo %q: %w", repoKey, err) + } + defer func() { _ = resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusBadRequest, http.StatusNotFound: + return errorutils.CheckErrorf("repository %q not found — check --repo or create the repository in Artifactory", repoKey) + default: + return errorutils.CheckErrorf("failed to validate --repo %q: Artifactory returned HTTP %d", repoKey, resp.StatusCode) + } +} + +func apkResolveCredentials(serverDetails *config.ServerDetails) (username, password string) { + if serverDetails == nil { + return "", "" + } + username = serverDetails.GetUser() + if storedPassword := serverDetails.GetPassword(); storedPassword != "" { + return username, storedPassword + } + token := serverDetails.GetAccessToken() + if token == "" { + return username, "" + } + if username == "" { + username = auth.ExtractUsernameFromAccessToken(token) + } + log.Warn(fmt.Sprintf("Embedding an access token in %s. Native apk commands will fail with "+ + "\"permission denied\" once the token expires, because nothing refreshes this file. "+ + "Re-run 'jf setup apk' to refresh it, or configure the server with a username and "+ + "password (or a long-lived token) to avoid this.", apkRepositoriesFile)) + return username, token +} + +func apkEmbedCredentials(repoURL, username, password string) (string, error) { + if username == "" && password == "" { + return repoURL, nil + } + parsed, err := url.Parse(repoURL) + if err != nil { + return "", errorutils.CheckErrorf("invalid repository URL %q: %s", repoURL, err.Error()) + } + parsed.User = url.UserPassword(username, password) + return parsed.String(), nil +} + +func (sc *SetupCommand) resolveApkRepoType() (string, error) { + if sc.projectKey != "" { + return "", nil + } + return promptApkRepoType() +} + +// promptApkRepoType interactively asks the user whether they want a local, remote, or virtual repo. +func promptApkRepoType() (string, error) { + repoTypes := []string{ + utils.Virtual.String(), + utils.Local.String(), + utils.Remote.String(), + } + var selected string + var items []ioutils.PromptItem + for _, rt := range repoTypes { + rt := rt + items = append(items, ioutils.PromptItem{Option: rt, TargetValue: &selected}) + } + if err := ioutils.SelectString(items, + "Select the Artifactory Alpine repository type you want to use (virtual is recommended):", + false, + func(item ioutils.PromptItem) { selected = item.Option }, + ); err != nil { + return "", err + } + return selected, nil +} + +// detectAlpineVersion reads /etc/alpine-release and returns the version tag (e.g. "v3.21"). +// Returns an empty string if the file is missing or unparseable — callers treat that as +// "version unknown, omit from URL". +func detectAlpineVersion() string { + data, err := os.ReadFile(alpineReleaseFile) + if err != nil { + return "" + } + ver := strings.TrimSpace(string(data)) + // Normalise "3.21.0" → "v3.21", "v3.21.0" → "v3.21". + ver = strings.TrimPrefix(ver, "v") + parts := strings.Split(ver, ".") + if len(parts) < 2 { + return "" + } + return "v" + parts[0] + "." + parts[1] +} + +// apkWriteSigningKey fetches the RSA public key for the repository from Artifactory and +// writes it to /etc/apk/keys/. Returns an error if the repo has no keypair configured +// or the key cannot be downloaded — the caller decides whether to warn or fail. +func apkWriteSigningKey(rtURL, repoKey string, serverDetails *config.ServerDetails) error { + keyPairRef, err := apkFetchKeyPairRef(rtURL, repoKey, serverDetails) + if err != nil { + return err + } + + keyEndpoint := fmt.Sprintf("%s/api/security/keypair/public/repositories/%s", rtURL, repoKey) + pemKey, err := apkDownloadRSAKey(keyEndpoint, serverDetails) + if err != nil { + return err + } + + if err = apkMkdirAll(apkKeysDir); err != nil { + return fmt.Errorf("failed to create %s: %w", apkKeysDir, err) + } + keyFilePath := filepath.Join(apkKeysDir, keyPairRef+".rsa.pub") + if err = apkWriteFile(keyFilePath, pemKey, 0644); err != nil { + return fmt.Errorf("failed to write RSA key to %s: %w", keyFilePath, err) + } + log.Info("RSA signing key written to", keyFilePath) + return nil +} + +// apkSetAuth attaches the appropriate Authorization header to the request. +// Prefers Bearer token; falls back to Basic auth when only username+password are set. +func apkSetAuth(req *http.Request, serverDetails *config.ServerDetails) { + if token := serverDetails.GetAccessToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + return + } + if user := serverDetails.GetUser(); user != "" { + req.SetBasicAuth(user, serverDetails.GetPassword()) + } +} + +// apkFetchKeyPairRef queries GET /api/repositories/ and returns the primaryKeyPairRef. +func apkFetchKeyPairRef(rtURL, repoKey string, serverDetails *config.ServerDetails) (string, error) { + endpoint := fmt.Sprintf("%s/api/repositories/%s", rtURL, repoKey) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + apkSetAuth(req, serverDetails) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GET %s returned HTTP %d", endpoint, resp.StatusCode) + } + + var repoConfig struct { + PrimaryKeyPairRef string `json:"primaryKeyPairRef"` + } + if err = json.Unmarshal(body, &repoConfig); err != nil { + return "", err + } + if repoConfig.PrimaryKeyPairRef == "" { + return "", fmt.Errorf("no primaryKeyPairRef configured on repo %q — attach a key pair in Artifactory first", repoKey) + } + return repoConfig.PrimaryKeyPairRef, nil +} + +// apkDownloadRSAKey downloads the RSA public key PEM from the Artifactory keypair API. +func apkDownloadRSAKey(endpoint string, serverDetails *config.ServerDetails) (string, error) { + if serverDetails.GetAccessToken() == "" && serverDetails.GetUser() == "" { + return "", fmt.Errorf("no credentials configured — run 'jf c add' first") + } + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + apkSetAuth(req, serverDetails) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("RSA key download failed with HTTP %d: %s", resp.StatusCode, string(body)) + } + pem := string(body) + if !strings.Contains(pem, "BEGIN PUBLIC KEY") { + return "", fmt.Errorf("unexpected response from RSA key endpoint (is a signing keypair configured on the repo?)") + } + return pem, nil +} + +// apkMkdirAll creates a directory, using sudo if the current process is not root. +func apkMkdirAll(path string) error { + if os.Getuid() == 0 { + return os.MkdirAll(path, 0755) + } + cmd := exec.Command("sudo", "mkdir", "-p", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// apkWriteFile writes content to path with the given permission bits, using sudo +// when the current process is not root. On the sudo path the file is created with an +// owner-only umask *before* any bytes are written, so a credential-bearing file is never +// briefly world-readable; the exact mode is then enforced with chmod. +func apkWriteFile(path, content string, perm os.FileMode) error { + if os.Getuid() == 0 { + if err := os.WriteFile(path, []byte(content), perm); err != nil { // #nosec G703 -- path is a hardcoded system file constant, not user input + return err + } + // os.WriteFile only applies perm when creating a new file; an existing file keeps + // its old mode. Chmod explicitly so the credential-bearing file is always locked down. + return os.Chmod(path, perm) + } + // `umask 077` creates the file owner-only from the start, avoiding the world-readable + // window a bare `sudo tee` would leave. path is a positional arg, not shell-interpolated. + cmd := exec.Command("sudo", "sh", "-c", `umask 077; cat > "$1"`, "sh", path) + cmd.Stdin = strings.NewReader(content) + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + // Widen to the exact requested mode (e.g. 0644 for the public signing key). + chmod := exec.Command("sudo", "chmod", fmt.Sprintf("%o", perm), path) + chmod.Stdout = io.Discard + chmod.Stderr = os.Stderr + return chmod.Run() +} + +func apkUpdateRepositories(repoURL string) error { + if err := apkMkdirAll(filepath.Dir(apkRepositoriesFile)); err != nil { + return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(apkRepositoriesFile), err) + } + + existing, err := os.ReadFile(apkRepositoriesFile) + fileExisted := err == nil + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", apkRepositoriesFile, err) + } + originalContent := string(existing) + content := apkMergeRepositoriesContent(originalContent, repoURL) + + if err = apkWriteFile(apkRepositoriesFile, content, 0600); err != nil { + // Restore whenever the file existed before — including when it was empty — so a + // partial write is never left behind. `originalContent != ""` would skip the + // restore for a previously-empty file. + if fileExisted { + if restoreErr := apkWriteFile(apkRepositoriesFile, originalContent, 0600); restoreErr != nil { + return fmt.Errorf("failed to write %s: %w (also failed to restore original content: %v)", apkRepositoriesFile, err, restoreErr) + } + } + return fmt.Errorf("failed to write %s: %w", apkRepositoriesFile, err) + } + log.Info(fmt.Sprintf("APK repository configured: %s → %s", apkRepositoriesFile, apkRedactCredentials(repoURL))) + return nil +} + +func apkMergeRepositoriesContent(existing, repoURL string) string { + repoURL = strings.TrimSpace(repoURL) + if existing == "" { + return repoURL + "\n" + } + + artHost := apkRepoHostname(repoURL) + existing = strings.TrimSuffix(existing, "\n") + lines := strings.Split(existing, "\n") + out := make([]string, 0, len(lines)+1) + inserted := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + out = append(out, line) + continue + } + if artHost != "" && apkRepoHostname(trimmed) == artHost { + if !inserted { + out = append(out, repoURL) + inserted = true + } + continue + } + out = append(out, line) + } + + if !inserted { + out = append([]string{repoURL}, out...) + } + return strings.Join(out, "\n") + "\n" +} + +func apkRepoHostname(repoLine string) string { + fields := strings.Fields(strings.TrimSpace(repoLine)) + if len(fields) == 0 { + return "" + } + candidate := fields[0] + if strings.HasPrefix(candidate, "@") { + if len(fields) < 2 { + return "" + } + candidate = fields[1] + } + parsed, err := url.Parse(candidate) + if err != nil || parsed.Host == "" { + return "" + } + return parsed.Hostname() +} + +func apkRedactCredentials(repoURL string) string { + parsed, err := url.Parse(repoURL) + if err != nil || parsed.User == nil { + return repoURL + } + // Splice in a literal masked userinfo instead of going through url.UserPassword + String(), + // which percent-encodes the mask ("*" -> "%2A") and prints noisy "%2A%2A%2A:%2A%2A%2A@host". + parsed.User = nil + return fmt.Sprintf("%s://***:***@%s", parsed.Scheme, strings.TrimPrefix(parsed.String(), parsed.Scheme+"://")) +} diff --git a/artifactory/commands/setup/setup_test.go b/artifactory/commands/setup/setup_test.go index b9b55684..0b58600d 100644 --- a/artifactory/commands/setup/setup_test.go +++ b/artifactory/commands/setup/setup_test.go @@ -1277,3 +1277,160 @@ func TestPackageManagerConfigs_CoversEverySupportedPackageManager(t *testing.T) } } } + +func TestApkValidateRepositoryExists(t *testing.T) { + tests := []struct { + name string + statusCode int + wantError string + }{ + {name: "existing repository", statusCode: http.StatusOK}, + {name: "missing repository", statusCode: http.StatusNotFound, wantError: `repository "alpine-local" not found`}, + {name: "bad request", statusCode: http.StatusBadRequest, wantError: `repository "alpine-local" not found`}, + {name: "unauthorized", statusCode: http.StatusUnauthorized, wantError: `Artifactory returned HTTP 401`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/repositories/alpine-local", r.URL.Path) + w.WriteHeader(test.statusCode) + })) + defer server.Close() + + serverDetails := &config.ServerDetails{ArtifactoryUrl: server.URL} + err := apkValidateRepositoryExists(server.URL, "alpine-local", serverDetails) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestResolveApkRepoTypeWithProject(t *testing.T) { + cmd := createTestSetupCommand(project.Apk) + + cmd.SetProjectKey("my-project") + repoType, err := cmd.resolveApkRepoType() + require.NoError(t, err) + assert.Empty(t, repoType) +} + +func TestApkMergeRepositoriesContent(t *testing.T) { + newRepo := "https://user:token@acme.jfrog.io/artifactory/alpine-virt/v3.20/main/" // #nosec G101 -- test fixture, not a real credential + + t.Run("empty file gets only the jfrog line", func(t *testing.T) { + got := apkMergeRepositoriesContent("", newRepo) + assert.Equal(t, newRepo+"\n", got) + }) + + t.Run("preserves public CDN and comments, prepends jfrog", func(t *testing.T) { + existing := `# Alpine mirrors +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +https://dl-cdn.alpinelinux.org/alpine/v3.20/community +` + got := apkMergeRepositoriesContent(existing, newRepo) + assert.Equal(t, newRepo+` +# Alpine mirrors +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +https://dl-cdn.alpinelinux.org/alpine/v3.20/community +`, got) + }) + + t.Run("overrides existing jfrog line for same host, keeps user lines", func(t *testing.T) { + // #nosec G101 -- test fixture, not a real credential + existing := `https://olduser:oldpass@acme.jfrog.io/artifactory/old-alpine/v3.19/main/ +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +https://mirror.example.com/alpine/edge/testing +` + got := apkMergeRepositoriesContent(existing, newRepo) + assert.Equal(t, newRepo+` +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +https://mirror.example.com/alpine/edge/testing +`, got) + }) + + t.Run("collapses multiple same-host jfrog lines into one", func(t *testing.T) { + // #nosec G101 -- test fixture, not a real credential + existing := `https://acme.jfrog.io/artifactory/alpine-a/v3.20/main/ +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +https://user:pass@acme.jfrog.io/artifactory/alpine-b/v3.20/community/ +` + got := apkMergeRepositoriesContent(existing, newRepo) + assert.Equal(t, newRepo+` +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +`, got) + }) + + t.Run("leaves a different artifactory host untouched", func(t *testing.T) { + existing := `https://other.jfrog.io/artifactory/other-alpine/v3.20/main/ +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +` + got := apkMergeRepositoriesContent(existing, newRepo) + assert.Equal(t, newRepo+` +https://other.jfrog.io/artifactory/other-alpine/v3.20/main/ +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +`, got) + }) + + t.Run("preserves alpine @tag lines", func(t *testing.T) { + existing := `@edge https://dl-cdn.alpinelinux.org/alpine/edge/main +https://dl-cdn.alpinelinux.org/alpine/v3.20/main +` + got := apkMergeRepositoriesContent(existing, newRepo) + assert.Contains(t, got, "@edge https://dl-cdn.alpinelinux.org/alpine/edge/main") + assert.Contains(t, got, "https://dl-cdn.alpinelinux.org/alpine/v3.20/main") + assert.True(t, strings.HasPrefix(strings.TrimSpace(got), newRepo)) + }) +} + +func TestApkRepoHostname(t *testing.T) { + assert.Equal(t, "acme.jfrog.io", apkRepoHostname("https://user:pass@acme.jfrog.io/artifactory/repo/v3.20/main/")) + assert.Equal(t, "acme.jfrog.io", apkRepoHostname("https://acme.jfrog.io/artifactory/repo/v3.20/main/")) + assert.Equal(t, "dl-cdn.alpinelinux.org", apkRepoHostname("@edge https://dl-cdn.alpinelinux.org/alpine/edge/main")) + assert.Empty(t, apkRepoHostname("# comment")) + assert.Empty(t, apkRepoHostname("/media/cdrom/apks")) +} + +func TestApkResolveCredentials_PrefersPasswordOverRefreshableToken(t *testing.T) { + // #nosec G101 -- test fixture, not a real credential + sd := &config.ServerDetails{ + User: "admin", + Password: "long-lived-password", + AccessToken: "short-lived-access-token", + ArtifactoryRefreshToken: "refresh-token", + } + + username, password := apkResolveCredentials(sd) + assert.Equal(t, "admin", username) + assert.Equal(t, "long-lived-password", password, + "the non-expiring password must be embedded, not the refreshable access token") +} + +func TestApkResolveCredentials_UsesTokenWhenNoPassword(t *testing.T) { + sd := &config.ServerDetails{User: "admin", AccessToken: "only-credential"} // #nosec G101 -- test fixture, not a real credential + + username, password := apkResolveCredentials(sd) + assert.Equal(t, "admin", username) + assert.Equal(t, "only-credential", password) +} + +func TestApkResolveCredentials_UsernameAndPasswordOnly(t *testing.T) { + sd := &config.ServerDetails{User: "admin", Password: "pass"} + + username, password := apkResolveCredentials(sd) + assert.Equal(t, "admin", username) + assert.Equal(t, "pass", password) +} + +func TestApkResolveCredentials_Anonymous(t *testing.T) { + username, password := apkResolveCredentials(&config.ServerDetails{}) + assert.Empty(t, username) + assert.Empty(t, password) + + username, password = apkResolveCredentials(nil) + assert.Empty(t, username) + assert.Empty(t, password) +} diff --git a/go.mod b/go.mod index c8dfc3a0..59c07c0d 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ require ( github.com/forPelevin/gomoji v1.4.1 github.com/google/go-containerregistry v0.21.3 github.com/jedib0t/go-pretty/v6 v6.8.3 - github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d + github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 5a89301c..d745bb5e 100644 --- a/go.sum +++ b/go.sum @@ -380,12 +380,18 @@ github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwO github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d h1:34G3TEVZfbpAFqAt/BiXrS4dA8vZfofkdW7qCQAYSgM= github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260805050828-b01fde2f4866 h1:ZFAA7CFV4NWnbStotsqkPC4xLiUaD3RrazzSJS3O+dE= +github.com/jfrog/build-info-go v1.13.1-0.20260805050828-b01fde2f4866/go.mod h1:S3s83Hw+Q6qtc97iIFhXsKhXXCXN1vVbyHkc/1TYMqo= +github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e h1:6ESp2Ouh1+apWklUhjRcf8l/2WuFp1hS9exnU/q3DEU= +github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e/go.mod h1:S3s83Hw+Q6qtc97iIFhXsKhXXCXN1vVbyHkc/1TYMqo= github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9JY= github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80 h1:V8wTPQAO/9MMxYFMM5qD08E8QRCmV3EtS8Gh+7SmJzU= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 h1:ivjpbI5ck0npW82c7A4VcDXW+Ez8Ewo7AYp5cGewBRg= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= github.com/jfrog/jfrog-cli-evidence v0.9.0/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 h1:o8fk4yWLqNMldarXyh/4NbmdbYbuM+lKYobdJK7shqM= From 6196485f76b46f40f9029760253dfbe5cce21d6c Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Fri, 7 Aug 2026 11:47:16 +0530 Subject: [PATCH 2/6] Skip --repo validation for apk add/upgrade when no server is configured Run() called ensureRepoExists unconditionally whenever --repo was set, even with no JFrog server configured at all. With no server there is nothing to validate against, and apk still works via the system's default repositories in that case (matching upload's existing guard). --- artifactory/commands/alpine/apkcommand.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/artifactory/commands/alpine/apkcommand.go b/artifactory/commands/alpine/apkcommand.go index 5f341dd7..abbdec32 100644 --- a/artifactory/commands/alpine/apkcommand.go +++ b/artifactory/commands/alpine/apkcommand.go @@ -153,7 +153,9 @@ func (apkCmd *ApkCommand) ServerDetails() (*config.ServerDetails, error) { func (apkCmd *ApkCommand) Run() error { // --repo is an explicit repository selection. Validate it before invoking native apk so // every wrapped subcommand fails consistently and clearly for an unknown repository. - if apkCmd.repoKey != "" { + // Skipped when no server is configured at all: apk still works against the system's + // default repositories in that case, so there is nothing to validate against. + if apkCmd.repoKey != "" && apkCmd.serverDetails != nil { if err := ensureRepoExists(apkCmd.repoKey, apkCmd.serverDetails); err != nil { return err } From 49177c07245accd5c907fb0c7549fe78a1c4a975 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Fri, 7 Aug 2026 12:22:21 +0530 Subject: [PATCH 3/6] Bump build-info-go to pick up the apk checksum/archive-parsing fixes --- go.mod | 2 +- go.sum | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 59c07c0d..104f6a07 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/forPelevin/gomoji v1.4.1 github.com/google/go-containerregistry v0.21.3 github.com/jedib0t/go-pretty/v6 v6.8.3 - github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e + github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 github.com/jfrog/jfrog-cli-evidence v0.9.0 diff --git a/go.sum b/go.sum index d745bb5e..86829301 100644 --- a/go.sum +++ b/go.sum @@ -378,18 +378,12 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d h1:34G3TEVZfbpAFqAt/BiXrS4dA8vZfofkdW7qCQAYSgM= -github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= -github.com/jfrog/build-info-go v1.13.1-0.20260805050828-b01fde2f4866 h1:ZFAA7CFV4NWnbStotsqkPC4xLiUaD3RrazzSJS3O+dE= -github.com/jfrog/build-info-go v1.13.1-0.20260805050828-b01fde2f4866/go.mod h1:S3s83Hw+Q6qtc97iIFhXsKhXXCXN1vVbyHkc/1TYMqo= -github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e h1:6ESp2Ouh1+apWklUhjRcf8l/2WuFp1hS9exnU/q3DEU= -github.com/jfrog/build-info-go v1.13.1-0.20260805061509-019102d3c22e/go.mod h1:S3s83Hw+Q6qtc97iIFhXsKhXXCXN1vVbyHkc/1TYMqo= +github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289 h1:RwvzxFXdL+YQWFrQywInFvx0HRhOZR5g1saEr9ZXE4M= +github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9JY= github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80 h1:V8wTPQAO/9MMxYFMM5qD08E8QRCmV3EtS8Gh+7SmJzU= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 h1:ivjpbI5ck0npW82c7A4VcDXW+Ez8Ewo7AYp5cGewBRg= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= From 5090c7261aada11e401e772660743542b2a42ef5 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Mon, 10 Aug 2026 13:38:30 +0530 Subject: [PATCH 4/6] RTECO-945 - Name CreateServiceManager HTTP retry defaults Co-authored-by: Cursor --- artifactory/commands/alpine/apkcommand.go | 2 +- artifactory/commands/alpine/apkupload.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/artifactory/commands/alpine/apkcommand.go b/artifactory/commands/alpine/apkcommand.go index abbdec32..0342759e 100644 --- a/artifactory/commands/alpine/apkcommand.go +++ b/artifactory/commands/alpine/apkcommand.go @@ -559,7 +559,7 @@ func (apkCmd *ApkCommand) enrichChecksumsFromAQL(deps []entities.Dependency) []e return deps } - sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, -1, 0, false) + sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, false) if err != nil { log.Debug("Could not create Artifactory service manager for AQL checksum enrichment:", err) return deps diff --git a/artifactory/commands/alpine/apkupload.go b/artifactory/commands/alpine/apkupload.go index 0efa4bff..c76d042b 100644 --- a/artifactory/commands/alpine/apkupload.go +++ b/artifactory/commands/alpine/apkupload.go @@ -30,6 +30,9 @@ const ( uploadThreads = 1 uploadHTTPRetries = 3 uploadHTTPRetryWaitMilliSecs = 0 + // Negative httpRetries tells CreateServiceManager to keep the client default retry count. + defaultHTTPRetries = -1 + defaultHTTPRetryWaitMilliSecs = 0 ) var apkFilenamePattern = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\.([^.]+)\.apk$`) @@ -368,7 +371,7 @@ func ensureRepoExists(repoKey string, serverDetails *config.ServerDetails) error repoKey, ) } - servicesManager, err := artutils.CreateServiceManager(serverDetails, -1, 0, false) + servicesManager, err := artutils.CreateServiceManager(serverDetails, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, false) if err != nil { return errorutils.CheckErrorf("failed to create Artifactory service manager to validate --repo '%s': %w", repoKey, err) } @@ -386,7 +389,7 @@ func ensureRepoExists(repoKey string, serverDetails *config.ServerDetails) error // If repoKey is a virtual repository, it resolves to its DefaultDeploymentRepo. // Returns an error if the virtual repo has no default deployment repo configured. func resolveLocalUploadRepo(repoKey string, serverDetails *config.ServerDetails) (string, error) { - servicesManager, err := artutils.CreateServiceManager(serverDetails, -1, 0, false) + servicesManager, err := artutils.CreateServiceManager(serverDetails, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, false) if err != nil { log.Debug("Could not create services manager for repo type check, using repo as-is:", err) return repoKey, nil @@ -484,7 +487,7 @@ func (apkCmd *ApkUploadCommand) enrichUploadDepsFromAQL(deps []entities.Dependen if len(missing) == 0 { return deps } - sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, -1, 0, false) + sm, err := artutils.CreateServiceManager(apkCmd.serverDetails, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, false) if err != nil { log.Debug("Could not create Artifactory service manager for upload AQL enrichment:", err) return deps From ed15f25b05139923e7b079351d28832ca5cdd32d Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Mon, 10 Aug 2026 13:56:43 +0530 Subject: [PATCH 5/6] RTECO-945 - Harden alpine path validation and bump jfrog-cli-core Co-authored-by: Cursor --- artifactory/commands/alpine/apkcommand.go | 19 +++++++++++++---- artifactory/commands/alpine/apkupload.go | 21 +++++++++++++++++-- artifactory/commands/alpine/apkupload_test.go | 9 ++++++++ go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/artifactory/commands/alpine/apkcommand.go b/artifactory/commands/alpine/apkcommand.go index 0342759e..9514ada1 100644 --- a/artifactory/commands/alpine/apkcommand.go +++ b/artifactory/commands/alpine/apkcommand.go @@ -371,6 +371,9 @@ func (apkCmd *ApkCommand) writeIsolatedRepositoriesFile() (string, error) { if apkCmd.repoKey == "" { return "", errorutils.CheckErrorf("--repo is required to build an isolated repositories file") } + if err := validateArtifactoryPathSegment("repo", apkCmd.repoKey); err != nil { + return "", err + } rtURL := strings.TrimRight(apkCmd.serverDetails.GetArtifactoryUrl(), "/") if rtURL == "" { return "", errorutils.CheckErrorf("the selected server has no Artifactory URL") @@ -385,6 +388,11 @@ func (apkCmd *ApkCommand) writeIsolatedRepositoriesFile() (string, error) { if version != "" && !strings.HasPrefix(version, "v") { version = "v" + version } + if version != "" { + if err := validateArtifactoryPathSegment("alpine-version", version); err != nil { + return "", err + } + } var repoURL string if version != "" { @@ -406,17 +414,20 @@ func (apkCmd *ApkCommand) writeIsolatedRepositoriesFile() (string, error) { if err != nil { return "", errorutils.CheckError(err) } + // Capture the CreateTemp path before any write so Remove/Chmod never depend on + // values derived from /etc/alpine-release or other stored inputs. + tmpPath := f.Name() defer func() { _ = f.Close() }() if _, err = f.WriteString(repoURL + "\n"); err != nil { - _ = os.Remove(f.Name()) + _ = os.Remove(tmpPath) return "", errorutils.CheckError(err) } // The file embeds a secret — lock it down. - if err = os.Chmod(f.Name(), 0600); err != nil { - _ = os.Remove(f.Name()) + if err = os.Chmod(tmpPath, 0600); err != nil { + _ = os.Remove(tmpPath) return "", errorutils.CheckError(err) } - return f.Name(), nil + return tmpPath, nil } // buildHTTPAuth constructs the HTTP_AUTH=basic::: string for apk-tools. diff --git a/artifactory/commands/alpine/apkupload.go b/artifactory/commands/alpine/apkupload.go index c76d042b..fa0c9dcb 100644 --- a/artifactory/commands/alpine/apkupload.go +++ b/artifactory/commands/alpine/apkupload.go @@ -699,7 +699,8 @@ func detectSystemArch() string { // detectSystemAlpineVersion reads /etc/alpine-release and returns the major.minor version // (e.g. "3.21", normalized later to "v3.21"). Returns "" when the file is absent or -// unparseable (i.e. not running on Alpine). +// unparseable (i.e. not running on Alpine). Only numeric major.minor segments are accepted +// so a compromised alpine-release cannot inject path separators or ".." into repo URLs. func detectSystemAlpineVersion() string { data, err := os.ReadFile("/etc/alpine-release") if err != nil { @@ -711,7 +712,23 @@ func detectSystemAlpineVersion() string { if len(parts) < 2 { return "" } - return parts[0] + "." + parts[1] + major, minor := parts[0], parts[1] + if !isNumericVersionSegment(major) || !isNumericVersionSegment(minor) { + return "" + } + return major + "." + minor +} + +func isNumericVersionSegment(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true } func resolveDepID(spec string) (string, error) { diff --git a/artifactory/commands/alpine/apkupload_test.go b/artifactory/commands/alpine/apkupload_test.go index e3e06ff8..769bbd15 100644 --- a/artifactory/commands/alpine/apkupload_test.go +++ b/artifactory/commands/alpine/apkupload_test.go @@ -42,6 +42,15 @@ func TestValidateArtifactoryPathSegment(t *testing.T) { } } +func TestIsNumericVersionSegment(t *testing.T) { + assert.True(t, isNumericVersionSegment("3")) + assert.True(t, isNumericVersionSegment("21")) + assert.False(t, isNumericVersionSegment("")) + assert.False(t, isNumericVersionSegment("3a")) + assert.False(t, isNumericVersionSegment("..")) + assert.False(t, isNumericVersionSegment("3/21")) +} + func TestAlpinePackageFromDepID(t *testing.T) { pkg := alpinePackageFromDepID("curl:8.5.0-r0") assert.Equal(t, "curl", pkg.Name) diff --git a/go.mod b/go.mod index 104f6a07..b18485d9 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289 github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 86829301..baeda3c7 100644 --- a/go.sum +++ b/go.sum @@ -384,8 +384,8 @@ github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9 github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58 h1:ivjpbI5ck0npW82c7A4VcDXW+Ez8Ewo7AYp5cGewBRg= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260805050922-db404c216e58/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a h1:UYbMSweDVj/Jp6lnPNjzBeYJWRAsIYLdJoemsb82MkU= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= github.com/jfrog/jfrog-cli-evidence v0.9.0/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 h1:o8fk4yWLqNMldarXyh/4NbmdbYbuM+lKYobdJK7shqM= From c6d8a30620e594497b5c5346c0e6999630ee4a01 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Tue, 11 Aug 2026 10:43:41 +0530 Subject: [PATCH 6/6] Updated core and build info go version --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index b18485d9..4866bf9a 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ require ( github.com/forPelevin/gomoji v1.4.1 github.com/google/go-containerregistry v0.21.3 github.com/jedib0t/go-pretty/v6 v6.8.3 - github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289 + github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64 github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index baeda3c7..553fc8c4 100644 --- a/go.sum +++ b/go.sum @@ -378,14 +378,14 @@ github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289 h1:RwvzxFXdL+YQWFrQywInFvx0HRhOZR5g1saEr9ZXE4M= -github.com/jfrog/build-info-go v1.13.1-0.20260807065030-d74800144289/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db h1:OnEYFZUq/LHlevMDQIdgRShVipvhBOnTISMhg8bWz2E= +github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9JY= github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a h1:UYbMSweDVj/Jp6lnPNjzBeYJWRAsIYLdJoemsb82MkU= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260810081705-ebb112a3637a/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64 h1:hH6TvfG+lXg9OfksRBpkIHG2Hhfzl8CDK5FtT83CDhY= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= github.com/jfrog/jfrog-cli-evidence v0.9.0/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 h1:o8fk4yWLqNMldarXyh/4NbmdbYbuM+lKYobdJK7shqM=