diff --git a/artifactory/commands/alpine/apkcommand.go b/artifactory/commands/alpine/apkcommand.go new file mode 100644 index 00000000..9514ada1 --- /dev/null +++ b/artifactory/commands/alpine/apkcommand.go @@ -0,0 +1,798 @@ +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. + // 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 + } + } + + 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") + } + 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") + } + + // 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 + } + if version != "" { + if err := validateArtifactoryPathSegment("alpine-version", version); err != nil { + return "", err + } + } + + 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) + } + // 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(tmpPath) + return "", errorutils.CheckError(err) + } + // The file embeds a secret — lock it down. + if err = os.Chmod(tmpPath, 0600); err != nil { + _ = os.Remove(tmpPath) + return "", errorutils.CheckError(err) + } + return tmpPath, 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, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, 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..fa0c9dcb --- /dev/null +++ b/artifactory/commands/alpine/apkupload.go @@ -0,0 +1,789 @@ +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 + // Negative httpRetries tells CreateServiceManager to keep the client default retry count. + defaultHTTPRetries = -1 + defaultHTTPRetryWaitMilliSecs = 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, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, 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, 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 + } + 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, defaultHTTPRetries, defaultHTTPRetryWaitMilliSecs, 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). 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 { + return "" + } + ver := strings.TrimSpace(string(data)) + ver = strings.TrimPrefix(ver, "v") + parts := strings.Split(ver, ".") + if len(parts) < 2 { + return "" + } + 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) { + 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..769bbd15 --- /dev/null +++ b/artifactory/commands/alpine/apkupload_test.go @@ -0,0 +1,176 @@ +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 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) + 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..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.20260610071651-260ad6720e0d + 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.20260804120604-edaa34435a80 + 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 5a89301c..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.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.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.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.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=