diff --git a/artifactory/commands/flexpack/maven.go b/artifactory/commands/flexpack/maven.go index 0010c610..d6ad116d 100644 --- a/artifactory/commands/flexpack/maven.go +++ b/artifactory/commands/flexpack/maven.go @@ -1,15 +1,12 @@ package flexpack import ( - "encoding/xml" "fmt" "net/url" "os" "path/filepath" "regexp" - "strconv" "strings" - "time" "github.com/jfrog/build-info-go/build" "github.com/jfrog/build-info-go/entities" @@ -19,60 +16,22 @@ import ( "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/log" ) -// PomProject represents the Maven POM XML structure for parsing -type PomProject struct { - XMLName xml.Name `xml:"project"` - GroupId string `xml:"groupId"` - ArtifactId string `xml:"artifactId"` - Version string `xml:"version"` - Packaging string `xml:"packaging"` - Parent PomParent `xml:"parent"` - DistributionManagement DistributionManagement `xml:"distributionManagement"` -} - -type PomParent struct { - GroupId string `xml:"groupId"` - ArtifactId string `xml:"artifactId"` - Version string `xml:"version"` -} - -type DistributionManagement struct { - Repository Repository `xml:"repository"` - SnapshotRepository Repository `xml:"snapshotRepository"` -} - -type Repository struct { - Id string `xml:"id"` - URL string `xml:"url"` -} - -// SettingsXml represents Maven settings.xml structure -type SettingsXml struct { - XMLName xml.Name `xml:"settings"` - ActiveProfiles []string `xml:"activeProfiles>activeProfile"` - Profiles []SettingsProfile `xml:"profiles>profile"` -} - -type SettingsProfile struct { - Id string `xml:"id"` - AltDeploymentRepository string `xml:"properties>altDeploymentRepository"` - AltReleaseDeploymentRepository string `xml:"properties>altReleaseDeploymentRepository"` - AltSnapshotDeploymentRepository string `xml:"properties>altSnapshotDeploymentRepository"` - Repositories []Repository `xml:"repositories>repository"` -} - -// CollectMavenBuildInfoWithFlexPack collects Maven build info using FlexPack -// This follows the same pattern as Poetry FlexPack in poetry.go -func CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber string, buildConfiguration *buildUtils.BuildConfiguration) error { +// CollectMavenBuildInfoWithFlexPack collects Maven build info using FlexPack. +// userArgs is the goal/flag list the user invoked Maven with; its resolution flags (-P/-s/-D) are +// forwarded to FlexPack so dependency resolution matches the build. This follows the same pattern as +// Poetry FlexPack in poetry.go. +func CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber string, buildConfiguration *buildUtils.BuildConfiguration, userArgs []string, serverDetails *config.ServerDetails) error { // Create Maven FlexPack configuration (following Poetry pattern) config := flexpack.MavenConfig{ WorkingDirectory: workingDir, IncludeTestDependencies: true, + ExtraArgs: extractResolutionArgs(userArgs), } // Create Maven FlexPack instance @@ -87,11 +46,28 @@ func CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber string return fmt.Errorf("failed to collect build info with FlexPack: %w", err) } - // Add deployed artifacts to build info if this was a deploy command - if wasDeployCommand() { - err = addDeployedArtifactsToBuildInfo(buildInfo, workingDir) - if err != nil { - log.Warn("Failed to add deployed artifacts to build info: " + err.Error()) + // For a deploy command, attach each module's deployed artifacts, then finalize them: record their + // real deployment repository (OriginalDeploymentRepo) and tag them with build properties. This runs + // BEFORE the build info is saved so OriginalDeploymentRepo is persisted for `rt bp`. It is + // best-effort - artifact bookkeeping must not fail the build. + if wasDeployCommand(userArgs) { + addDeployedArtifactsToBuildInfo(buildInfo, mavenFlex.GetModuleLocations()) + // Resolve the deployment repositories from Maven's effective model (effective-pom/settings), so + // they are correct under interpolation, inheritance and active profiles. moduleDeployURLs maps + // each module to its own repo (reactors may deploy modules to different repos); overrideURL, when + // set (-DaltDeploymentRepository / settings), applies to all modules. + moduleDeployURLs, overrideURL, repoErr := mavenFlex.GetDeploymentRepositories() + if repoErr != nil { + log.Warn("Failed to resolve Maven deployment repository: " + repoErr.Error()) + } + // Ensure the build's general details (start timestamp) exist before finalizing, so build.timestamp + // resolves to the build's real timestamp. finalize runs before the build info is saved, so the + // save's own GetOrCreateBuild has not recorded the timestamp yet. + if _, buildErr := build.NewBuildInfoService().GetOrCreateBuildWithProject(buildName, buildNumber, buildConfiguration.GetProject()); buildErr != nil { + log.Warn("Failed to initialize build details for timestamp: " + buildErr.Error()) + } + if err := finalizeDeployedArtifacts(workingDir, buildInfo, moduleDeployURLs, overrideURL, buildName, buildNumber, buildConfiguration, serverDetails); err != nil { + log.Warn("Failed to finalize deployed artifacts: " + err.Error()) } } @@ -103,15 +79,6 @@ func CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber string log.Info("Build info saved locally. Use 'jf rt bp " + buildName + " " + buildNumber + "' to publish it to Artifactory.") } - // Set build properties on deployed artifacts if this was a deploy command - if wasDeployCommand() { - err = setMavenBuildPropertiesOnArtifacts(workingDir, buildName, buildNumber, buildConfiguration) - if err != nil { - log.Warn("Failed to set build properties on deployed artifacts: " + err.Error()) - // Don't fail the entire operation for property setting issues - } - } - return nil } @@ -131,10 +98,11 @@ func saveMavenFlexPackBuildInfo(buildInfo *entities.BuildInfo) error { return buildInstance.SaveBuildInfo(buildInfo) } -// wasDeployCommand checks if the current command was a Maven deploy command -func wasDeployCommand() bool { - args := os.Args - for _, arg := range args { +// wasDeployCommand reports whether the Maven goals include a deploy goal. It inspects the parsed +// invocation passed in (the same userArgs forwarded for resolution-flag extraction) rather than the +// process-global os.Args, so the decision is testable and consistent with the rest of the flow. +func wasDeployCommand(userArgs []string) bool { + for _, arg := range userArgs { // Match standalone "deploy" goal or any deploy plugin goal // Examples: deploy, deploy:deploy, deploy:deploy-file, maven-deploy-plugin:deploy if arg == "deploy" || strings.HasPrefix(arg, "deploy:") || strings.HasSuffix(arg, ":deploy") { @@ -144,372 +112,199 @@ func wasDeployCommand() bool { return false } -// setMavenBuildPropertiesOnArtifacts sets build properties on deployed Maven artifacts -// Following the pattern from twine.go -func setMavenBuildPropertiesOnArtifacts(workingDir, buildName, buildNumber string, buildArgs *buildUtils.BuildConfiguration) error { - // Get server details from configuration - serverDetails, err := config.GetDefaultServerConf() - if err != nil { - return fmt.Errorf("failed to get server details: %w", err) +// finalizeDeployedArtifacts records each deployed artifact's real deployment repository on the build +// info (OriginalDeploymentRepo) and tags the deployed items with build.name/build.number/build.timestamp +// so Artifactory links them to the build (this resolves the artifact "path" in the build UI; without it +// the artifacts show as "externally resolved / No path found"). +// +// deployRepoURL is Maven's EFFECTIVE deployment URL (resolved from effective-pom/effective-settings, so +// inheritance, interpolation and active profiles are already applied). Its repo key is resolved to the +// physical repository - a virtual repo becomes its default-deployment local repo, via GetRepository - +// and artifacts are then matched by sha256 scoped to that repo. That way identical content stored +// elsewhere is never mis-tagged, and no repository path layout is assumed. build.timestamp comes from +// the build's real timestamp via buildUtils.CreateBuildProperties. +// +// Must run BEFORE the build info is saved so OriginalDeploymentRepo is persisted for `rt bp`. +func finalizeDeployedArtifacts(workingDir string, buildInfo *entities.BuildInfo, moduleDeployURLs map[string]string, overrideURL, buildName, buildNumber string, buildArgs *buildUtils.BuildConfiguration, serverDetails *config.ServerDetails) error { + if overrideURL == "" && len(moduleDeployURLs) == 0 { + log.Warn("Could not determine any Maven deployment repository; skipping build-property tagging") + return nil } + // serverDetails comes from the resolved server-id (falls back to the default configured server). + if serverDetails == nil { + var err error + if serverDetails, err = config.GetDefaultServerConf(); err != nil { + return fmt.Errorf("failed to get server details: %w", err) + } + } if serverDetails == nil { - log.Debug("No server details configured, skipping build properties setting") + log.Debug("No server details configured, skipping deployed-artifact finalization") return nil } - - // Create services manager servicesManager, err := utils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { return fmt.Errorf("failed to create services manager: %w", err) } - // Get Maven artifact info from pom.xml - groupId, artifactId, version, err := getMavenArtifactCoordinates(workingDir) - if err != nil { - return fmt.Errorf("failed to get Maven artifact coordinates: %w", err) - } - - // Get the repository Maven deployed to from settings.xml or pom.xml - targetRepo, err := getMavenDeployRepository(workingDir) - if err != nil { - log.Warn("Could not determine Maven deploy repository, skipping build properties: " + err.Error()) - return nil - } - - // Create search pattern for the specific deployed artifacts in the target repository - artifactPath := fmt.Sprintf("%s/%s/%s/%s/%s-*", - targetRepo, - strings.ReplaceAll(groupId, ".", "/"), artifactId, version, artifactId) - - // Search for deployed artifacts using the specific pattern - searchParams := services.SearchParams{ - CommonParams: &specutils.CommonParams{ - Pattern: artifactPath, - }, - } - - searchReader, err := servicesManager.SearchFiles(searchParams) - if err != nil { - return fmt.Errorf("failed to search for deployed artifacts: %w", err) - } - defer func() { - if closeErr := searchReader.Close(); closeErr != nil { - log.Debug(fmt.Sprintf("Failed to close search reader: %s", closeErr)) + // Group each module's artifact checksums by the PHYSICAL repo it deployed to (resolving virtual -> + // default-deployment). Modules may deploy to DIFFERENT repos, so this is per-module, not one repo + // for the whole reactor. overrideURL (-DaltDeploymentRepository / settings) applies to every module. + physicalByKey := make(map[string]string) // repoKey -> physical repo (GetRepository cache) + sha256sByRepo := make(map[string][]string) // physical repo -> checksums to tag + for i := range buildInfo.Modules { + module := &buildInfo.Modules[i] + deployURL := overrideURL + if deployURL == "" { + deployURL = moduleDeployURLs[module.Id] } - }() - - // Filter to only artifacts modified in the last 2 minutes (just deployed) - cutoffTime := time.Now().Add(-2 * time.Minute) - var recentArtifacts []specutils.ResultItem - - for item := new(specutils.ResultItem); searchReader.NextRecord(item) == nil; item = new(specutils.ResultItem) { - // Parse the modified time - modTime, err := time.Parse("2006-01-02T15:04:05.999Z", item.Modified) - if err != nil { - log.Debug("Could not parse modified time for " + item.Name + ": " + err.Error()) + if deployURL == "" { + log.Debug("No deployment repository for module " + module.Id + "; skipping its build-property tagging") continue } - - // Only include artifacts modified after cutoff - if modTime.After(cutoffTime) { - recentArtifacts = append(recentArtifacts, *item) - } - } - - if len(recentArtifacts) == 0 { - log.Warn("No recently deployed artifacts found") - return nil - } - - // Create build properties in the same format as NPM/traditional implementations - timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) // Unix milliseconds like NPM - buildProps := fmt.Sprintf("build.name=%s;build.number=%s;build.timestamp=%s", buildName, buildNumber, timestamp) - if projectKey := buildArgs.GetProject(); projectKey != "" { - buildProps += fmt.Sprintf(";build.project=%s", projectKey) - } - - buildProps = civcs.MergeWithUserProps(buildProps, workingDir) - - // Set properties on each recent artifact individually - for _, artifact := range recentArtifacts { - // ResultItem has Repo, Path, and Name fields already separated - // Use AQL to find the exact artifact - aqlPattern := fmt.Sprintf(`{"repo":"%s","path":"%s","name":"%s"}`, - targetRepo, artifact.Path, artifact.Name) - - searchParams := services.SearchParams{ - CommonParams: &specutils.CommonParams{ - Aql: specutils.Aql{ - ItemsFind: aqlPattern, - }, - }, - } - - reader, err := servicesManager.SearchFiles(searchParams) - if err != nil { - log.Warn(fmt.Sprintf("Failed to search for artifact %s: %s", artifact.Name, err)) + repoKey, keyErr := extractRepoKeyFromUrl(deployURL) + if keyErr != nil { + log.Warn("Skipping module " + module.Id + ": " + keyErr.Error()) continue } - - propsParams := services.PropsParams{ - Reader: reader, - Props: buildProps, + physicalRepo, cached := physicalByKey[repoKey] + if !cached { + physicalRepo = resolvePhysicalDeployRepo(servicesManager, repoKey) + physicalByKey[repoKey] = physicalRepo } - - _, err = servicesManager.SetProps(propsParams) - if closeErr := reader.Close(); closeErr != nil { - log.Debug(fmt.Sprintf("Failed to close reader for %s: %s", artifact.Name, closeErr)) + if physicalRepo == "" { + log.Warn("Could not resolve a physical deployment repository for '" + repoKey + "'; skipping module " + module.Id) + continue } - if err != nil { - log.Warn(fmt.Sprintf("Failed to set properties on %s: %s", artifact.Name, err)) + // Record the module's real deployment repo, and collect its checksums for tagging in that repo. + for j := range module.Artifacts { + if module.Artifacts[j].OriginalDeploymentRepo == "" { + module.Artifacts[j].OriginalDeploymentRepo = physicalRepo + } + if module.Artifacts[j].Sha256 != "" { + sha256sByRepo[physicalRepo] = append(sha256sByRepo[physicalRepo], module.Artifacts[j].Sha256) + } } } - log.Info("Successfully set build properties on deployed Maven artifacts") - return nil -} + if len(sha256sByRepo) == 0 { + log.Warn("No deployed artifacts with checksums found; skipping build-property tagging") + return nil + } -// getSettingsXmlPath finds the Maven settings.xml file -func getSettingsXmlPath() string { - // Check if -s or --settings flag was used - args := os.Args - for i, arg := range args { - if (arg == "-s" || arg == "--settings") && i+1 < len(args) { - return args[i+1] + // One AQL + one (parallelized) SetProps per physical repo. + buildProps := mavenBuildProperties(buildName, buildNumber, buildArgs.GetProject(), workingDir) + total := 0 + for physicalRepo, sha256s := range sha256sByRepo { + count, tagErr := tagArtifactsInRepo(servicesManager, physicalRepo, sha256s, buildProps) + if tagErr != nil { + log.Warn("Failed to set build properties in '" + physicalRepo + "': " + tagErr.Error()) + continue } + total += count } - - // Default location: ~/.m2/settings.xml - homeDir, err := os.UserHomeDir() - if err != nil { - return "" + if total == 0 { + log.Warn("No deployed artifacts found to tag with build properties") + return nil } - return filepath.Join(homeDir, ".m2", "settings.xml") + log.Info(fmt.Sprintf("Set build properties on %d deployed Maven artifact(s)", total)) + return nil } -// parseSettingsXml reads and parses Maven settings.xml -func parseSettingsXml(settingsPath string) (*SettingsXml, error) { - if settingsPath == "" { - return nil, fmt.Errorf("settings.xml path cannot be empty") - } - if strings.Contains(settingsPath, "..") { - return nil, fmt.Errorf("path traversal detected in settings.xml path: %s", settingsPath) - } - absPath, err := filepath.Abs(settingsPath) +// tagArtifactsInRepo tags every artifact in repo whose content matches one of sha256s with buildProps, +// using a single AQL search + a single SetProps call. Returns the number of artifacts tagged. +func tagArtifactsInRepo(servicesManager artifactory.ArtifactoryServicesManager, repo string, sha256s []string, buildProps string) (int, error) { + reader, err := servicesManager.SearchFiles(services.SearchParams{ + CommonParams: &specutils.CommonParams{Aql: specutils.Aql{ItemsFind: checksumAql(repo, sha256s)}}, + }) if err != nil { - return nil, fmt.Errorf("failed to resolve absolute path for settings.xml: %w", err) + return 0, err } - cleanedPath := filepath.Clean(absPath) - if cleanedPath != absPath { - return nil, fmt.Errorf("invalid path detected: %s", settingsPath) + count, setErr := servicesManager.SetProps(services.PropsParams{Reader: reader, Props: buildProps}) + if closeErr := reader.Close(); closeErr != nil { + log.Debug("Failed to close search reader: " + closeErr.Error()) } - - data, err := os.ReadFile(absPath) - if err != nil { - return nil, err + if setErr != nil { + return 0, setErr } - - var settings SettingsXml - if err := xml.Unmarshal(data, &settings); err != nil { - return nil, err - } - - return &settings, nil + return count, nil } -// isSnapshotVersion checks if a Maven version is a SNAPSHOT -func isSnapshotVersion(version string) bool { - return strings.HasSuffix(strings.TrimSpace(version), "-SNAPSHOT") -} - -// extractRepoFromAltProperty parses the alt*DeploymentRepository format: "id::layout::url" -func extractRepoFromAltProperty(altRepo string) (string, error) { - parts := strings.Split(altRepo, "::") - if len(parts) >= 3 { - repoUrl := parts[2] - return extractRepoKeyFromUrl(repoUrl) - } - return "", fmt.Errorf("invalid alt deployment repository format: %s", altRepo) -} - -// getRepositoryFromSettings extracts deployment repository from settings.xml -// based on whether the project is a SNAPSHOT or RELEASE version -func getRepositoryFromSettings(isSnapshot bool) (string, error) { - settingsPath := getSettingsXmlPath() - if settingsPath == "" { - return "", fmt.Errorf("could not determine settings.xml path") - } - - settings, err := parseSettingsXml(settingsPath) - if err != nil { - return "", err - } - - // Check active profiles for alt*DeploymentRepository with Maven's actual precedence - // Maven prioritizes SPECIFIC (altSnapshot/altRelease) over GENERAL (altDeployment) - for _, profileId := range settings.ActiveProfiles { - for _, profile := range settings.Profiles { - if profile.Id == profileId { - // Priority 1: altSnapshotDeploymentRepository or altReleaseDeploymentRepository (SPECIFIC wins) - if isSnapshot && profile.AltSnapshotDeploymentRepository != "" { - log.Debug("Found altSnapshotDeploymentRepository in settings.xml (specific for SNAPSHOT)") - return extractRepoFromAltProperty(profile.AltSnapshotDeploymentRepository) - } - - if !isSnapshot && profile.AltReleaseDeploymentRepository != "" { - log.Debug("Found altReleaseDeploymentRepository in settings.xml (specific for RELEASE)") - return extractRepoFromAltProperty(profile.AltReleaseDeploymentRepository) - } - - // Priority 2: altDeploymentRepository (GENERAL fallback) - if profile.AltDeploymentRepository != "" { - log.Debug("Found altDeploymentRepository in settings.xml (general fallback)") - return extractRepoFromAltProperty(profile.AltDeploymentRepository) - } - } +// resolvePhysicalDeployRepo returns the physical repository that stores artifacts deployed to repoKey. +// If repoKey is a virtual repository, its configured defaultDeploymentRepo is returned (empty if none, +// which is unusable); otherwise repoKey is returned unchanged. Mirrors the pnpm/nix/docker FlexPack pattern. +func resolvePhysicalDeployRepo(servicesManager artifactory.ArtifactoryServicesManager, repoKey string) string { + repoDetails := &services.VirtualRepositoryBaseParams{} + if err := servicesManager.GetRepository(repoKey, repoDetails); err != nil { + log.Debug(fmt.Sprintf("Could not read repository '%s', using as-is: %s", repoKey, err.Error())) + return repoKey + } + if repoDetails.Rclass == services.VirtualRepositoryRepoType { + if repoDetails.DefaultDeploymentRepo == "" { + log.Warn("Virtual repository '" + repoKey + "' has no default deployment repository configured; " + + "cannot tag deployed artifacts. Configure one, or deploy to a local repository.") + return "" } + log.Debug("Resolved virtual repository '" + repoKey + "' to default deployment repository '" + repoDetails.DefaultDeploymentRepo + "'") + return repoDetails.DefaultDeploymentRepo } - - return "", fmt.Errorf("no deployment repository found in settings.xml") + return repoKey } -// extractRepoKeyFromUrl extracts repository key from Artifactory URL using proper URL parsing +// extractRepoKeyFromUrl extracts the repository key from an Artifactory deployment URL, handling both +// the "/artifactory/" and "/artifactory/api/maven/" forms (the repo key is the last segment). func extractRepoKeyFromUrl(repoUrl string) (string, error) { repoUrl = strings.TrimSpace(repoUrl) - - // Parse the URL u, err := url.Parse(repoUrl) if err != nil { return "", fmt.Errorf("invalid repository URL: %w", err) } - - // Split path into segments, removing empty strings segments := strings.Split(strings.Trim(u.Path, "/"), "/") - - // Handle /api/maven/REPO-KEY format - // Path: /artifactory/api/maven/REPO-KEY - // Segments: [artifactory, api, maven, REPO-KEY] + // /artifactory/api/maven/ if len(segments) >= 4 && segments[len(segments)-3] == "api" && segments[len(segments)-2] == "maven" { - repoKey := segments[len(segments)-1] - if repoKey != "" { + if repoKey := segments[len(segments)-1]; repoKey != "" { return repoKey, nil } } - - // Standard format: /artifactory/REPO-KEY - // Segments: [artifactory, REPO-KEY] - // The last segment is the repository key - if len(segments) >= 2 { - repoKey := segments[len(segments)-1] - if repoKey != "" { - return repoKey, nil - } - } - - return "", fmt.Errorf("unable to extract repository key from URL: %s (check repository URL format)", repoUrl) -} - -// getMavenDeployRepository determines where Maven deployed artifacts -// by parsing pom.xml distributionManagement, with fallback to settings.xml -func getMavenDeployRepository(workingDir string) (string, error) { - pomPath := filepath.Join(workingDir, "pom.xml") - pomData, err := os.ReadFile(pomPath) - if err != nil { - return "", fmt.Errorf("failed to read pom.xml: %w", err) - } - - var pom PomProject - if err := xml.Unmarshal(pomData, &pom); err != nil { - return "", fmt.Errorf("failed to parse pom.xml: %w", err) - } - - // Determine project version to know if it's SNAPSHOT or RELEASE - version := pom.Version - if version == "" && pom.Parent.Version != "" { - version = pom.Parent.Version - } - isSnapshot := isSnapshotVersion(version) - log.Debug(fmt.Sprintf("Project version: %s, isSnapshot: %v", version, isSnapshot)) - - // Priority 1: Check settings.xml (Maven standard precedence) - // settings.xml alt*DeploymentRepository overrides pom.xml in Maven - repoKey, err := getRepositoryFromSettings(isSnapshot) - if err == nil { - log.Debug("Found deploy repository from settings.xml (overriding pom.xml): " + repoKey) + // /artifactory/, or just when Artifactory is at the host root. + if repoKey := segments[len(segments)-1]; repoKey != "" { return repoKey, nil } + return "", fmt.Errorf("unable to extract repository key from URL: %s", repoUrl) +} - // Priority 2: Check pom.xml distributionManagement - var repoUrl string - switch { - case isSnapshot && pom.DistributionManagement.SnapshotRepository.URL != "": - repoUrl = pom.DistributionManagement.SnapshotRepository.URL - log.Debug("Using snapshotRepository from pom.xml") - case !isSnapshot && pom.DistributionManagement.Repository.URL != "": - repoUrl = pom.DistributionManagement.Repository.URL - log.Debug("Using repository from pom.xml") - case pom.DistributionManagement.Repository.URL != "": - // Fallback: use release repository if snapshot not defined - repoUrl = pom.DistributionManagement.Repository.URL - log.Debug("Using repository (fallback) from pom.xml") - } - - if repoUrl != "" { - repoKey, err := extractRepoKeyFromUrl(repoUrl) - if err == nil { - log.Debug("Found deploy repository from pom.xml: " + repoKey) - return repoKey, nil +// checksumAql builds an AQL find body matching any of the given sha256 checksums within repo. sha256 is +// the collision-resistant content identifier; scoping to the (physical) deploy repo keeps identical +// content stored elsewhere from being matched, without assuming any repository path layout. +func checksumAql(repo string, sha256s []string) string { + var b strings.Builder + b.WriteString(`{"repo":"`) + b.WriteString(repo) + b.WriteString(`","$or":[`) + for i, sha256 := range sha256s { + if i > 0 { + b.WriteByte(',') } - log.Debug("Failed to extract repository from pom.xml URL: " + err.Error()) + b.WriteString(`{"sha256":"`) + b.WriteString(sha256) + b.WriteString(`"}`) } - - return "", fmt.Errorf("no deployment repository found in settings.xml or pom.xml") + b.WriteString("]}") + return b.String() } -// getMavenArtifactCoordinates extracts Maven coordinates from pom.xml -func getMavenArtifactCoordinates(workingDir string) (groupId, artifactId, version string, err error) { - pomPath := filepath.Join(workingDir, "pom.xml") - pomData, err := os.ReadFile(pomPath) +// mavenBuildProperties builds the build.name;build.number;build.timestamp property string (timestamp is +// the build's real timestamp, matching the published build-info and the docker build/buildx convention), +// plus the optional build.project and any user-configured properties. +func mavenBuildProperties(buildName, buildNumber, projectKey, workingDir string) string { + buildProps, err := buildUtils.CreateBuildProperties(buildName, buildNumber, projectKey) if err != nil { - return "", "", "", fmt.Errorf("failed to read pom.xml: %w", err) - } - - var pom PomProject - if err := xml.Unmarshal(pomData, &pom); err != nil { - return "", "", "", fmt.Errorf("failed to parse pom.xml: %w", err) - } - - // Use project values, fallback to parent if missing - groupId = pom.GroupId - if groupId == "" { - groupId = pom.Parent.GroupId - } - - artifactId = pom.ArtifactId - - version = pom.Version - if version == "" { - version = pom.Parent.Version - } - - if groupId == "" || artifactId == "" || version == "" { - return "", "", "", fmt.Errorf("failed to extract complete Maven coordinates from pom.xml (groupId=%s, artifactId=%s, version=%s)", groupId, artifactId, version) + log.Debug("Build timestamp unavailable, tagging with name/number only: " + err.Error()) } - - // Validate Maven coordinates to prevent path traversal attacks via crafted pom.xml. - // Maven groupId/artifactId/version legitimately cannot contain path separators or ".." sequences. - if err := validateMavenCoordinate(groupId); err != nil { - return "", "", "", fmt.Errorf("invalid groupId in pom.xml: %w", err) - } - if err := validateMavenCoordinate(artifactId); err != nil { - return "", "", "", fmt.Errorf("invalid artifactId in pom.xml: %w", err) - } - if err := validateMavenCoordinate(version); err != nil { - return "", "", "", fmt.Errorf("invalid version in pom.xml: %w", err) + if projectKey != "" { + buildProps += fmt.Sprintf(";build.project=%s", projectKey) } - - return groupId, artifactId, version, nil + return civcs.MergeWithUserProps(buildProps, workingDir) } // mavenCoordinateRegex is an allowlist of the only characters that may legitimately appear in a @@ -550,96 +345,147 @@ func validateMavenCoordinate(value string) error { return nil } -// addDeployedArtifactsToBuildInfo adds deployed artifacts to the build info -func addDeployedArtifactsToBuildInfo(buildInfo *entities.BuildInfo, workingDir string) error { - // Find the target directory with built artifacts - targetDir := filepath.Join(workingDir, "target") - if _, err := os.Stat(targetDir); os.IsNotExist(err) { - log.Debug("No target directory found, skipping artifact collection") - return nil +// resolutionArgReferencesSettings reports whether arg selects a settings file (its value may be the +// following token). +// isSeparateValueFlag reports whether arg is a resolution flag that consumes the next token as its value. +func isSeparateValueFlag(arg string) bool { + return arg == "-s" || arg == "--settings" || + arg == "-f" || arg == "--file" || + arg == "-gs" || arg == "--global-settings" +} + +// extractResolutionArgs picks the resolution-affecting flags out of the user's Maven invocation so +// they can be replayed on the internal `mvn dependency:tree` call. Forwarded flags: +// - -P / --activate-profiles active profiles +// - -D / --define system property overrides +// - -s / --settings user settings file (value in next token or = form) +// - -f / --file alternate POM (changes what gets resolved) +// - -gs / --global-settings global settings file +// - -o / --offline offline mode +// +// Everything else (goals, deploy-only flags, etc.) is ignored. +func extractResolutionArgs(userArgs []string) []string { + var extracted []string + for i := 0; i < len(userArgs); i++ { + arg := userArgs[i] + switch { + case strings.HasPrefix(arg, "-P"), strings.HasPrefix(arg, "--activate-profiles="), + strings.HasPrefix(arg, "-D"), strings.HasPrefix(arg, "--define="), + strings.HasPrefix(arg, "--settings="), strings.HasPrefix(arg, "-f="), + strings.HasPrefix(arg, "--file="), strings.HasPrefix(arg, "-gs="), + strings.HasPrefix(arg, "--global-settings="), + arg == "-o", arg == "--offline": + extracted = append(extracted, arg) + case isSeparateValueFlag(arg), + arg == "--activate-profiles", arg == "--define": + extracted = append(extracted, arg) + // These flags take their value as the next token. + if i+1 < len(userArgs) { + i++ + extracted = append(extracted, userArgs[i]) + } + } } + return extracted +} - // Get Maven artifact coordinates - groupId, artifactId, version, err := getMavenArtifactCoordinates(workingDir) - if err != nil { - return fmt.Errorf("failed to get Maven artifact coordinates: %w", err) +// addDeployedArtifactsToBuildInfo attaches each module's deployed artifacts to the matching build-info +// module. locations is FlexPack's authoritative id -> location map (built from what Maven actually +// ran, profile-activated modules included), so no pom re-discovery is needed and submodule artifacts +// are no longer dropped (previously only Modules[0] received artifacts). +func addDeployedArtifactsToBuildInfo(buildInfo *entities.BuildInfo, locations map[string]flexpack.ModuleLocation) { + if len(buildInfo.Modules) == 0 { + log.Warn("No modules found in build info, cannot add artifacts") + return } - // Get packaging type from pom.xml - packagingType := getPackagingType(workingDir) + for i := range buildInfo.Modules { + module := &buildInfo.Modules[i] + location, ok := locations[module.Id] + if !ok { + log.Debug("No build location found for module " + module.Id + ", skipping artifact collection") + continue + } + if artifacts := collectModuleArtifacts(module.Id, location); len(artifacts) > 0 { + module.Artifacts = artifacts + } + } +} + +// collectModuleArtifacts collects the artifacts a single module produces: its main artifact (matching +// the packaging) from target/, and its pom.xml. Coordinates come from the module id and the build +// directory/packaging from FlexPack's recorded location, so no pom.xml is re-parsed. Modules without a +// target directory (e.g. a pom aggregator) yield only the pom artifact. +func collectModuleArtifacts(moduleId string, location flexpack.ModuleLocation) []entities.Artifact { + groupId, artifactId, version, ok := splitModuleId(moduleId) + if !ok { + log.Warn("Skipping artifacts for module with invalid id: " + moduleId) + return nil + } - // Create artifacts for the deployed files var artifacts []entities.Artifact - // Only include the main artifact that matches the packaging type - // This follows traditional Maven behavior where intermediate build artifacts (e.g., .jar in WAR projects) are excluded - mainArtifactName := fmt.Sprintf("%s-%s.%s", artifactId, version, packagingType) - // Defense in depth: re-validate the composed filename before joining it with targetDir. - // The individual inputs are already sanitized at extraction, but composing them (version "." packaging) - // could still produce ".." at a boundary (e.g. a trailing-dot version "1.0." -> "app-1.0..jar"). - // Reusing validateMavenCoordinate keeps the traversal rules in a single place. - if err := validateMavenCoordinate(mainArtifactName); err != nil { - return fmt.Errorf("invalid artifact name: %w", err) - } - // filepath.Base strips any directory component, guaranteeing the artifact is read from directly - // within targetDir regardless of the (already validated) input. This collapses the value to a - // single path element and is the canonical sanitizer for stored path-traversal data flows. - mainArtifactName = filepath.Base(mainArtifactName) - mainArtifactPath := filepath.Join(targetDir, mainArtifactName) - - if _, err := os.Stat(mainArtifactPath); err == nil { - artifact := createArtifactFromFile(mainArtifactPath, groupId, artifactId, version, packagingType) - artifacts = append(artifacts, artifact) - } - - // Add POM artifact (from project root, not target) - pomArtifactName := fmt.Sprintf("%s-%s.pom", artifactId, version) - pomArtifactPath := filepath.Join(workingDir, "pom.xml") - - if _, err := os.Stat(pomArtifactPath); err == nil { - artifact := createArtifactFromFile(pomArtifactPath, groupId, artifactId, version, "pom") - artifact.Name = pomArtifactName - artifact.Path = fmt.Sprintf("%s/%s/%s/%s", strings.ReplaceAll(groupId, ".", "/"), artifactId, version, pomArtifactName) - artifacts = append(artifacts, artifact) - } - - // Add artifacts to the first module (Maven projects typically have one module) - if len(buildInfo.Modules) > 0 { - buildInfo.Modules[0].Artifacts = artifacts - } else { - log.Warn("No modules found in build info, cannot add artifacts") + // Main artifact matching the packaging type (only present for buildable modules, not pom aggregators). + // This follows traditional Maven behavior where intermediate build artifacts (e.g., .jar in WAR projects) are excluded. + targetDir := filepath.Join(location.Dir, "target") + if _, statErr := os.Stat(targetDir); statErr == nil { + packagingType := sanitizePackaging(location.Packaging) + mainArtifactName := fmt.Sprintf("%s-%s.%s", artifactId, version, packagingType) + // Defense in depth: re-validate the composed filename before joining it with targetDir. + // The individual inputs are already sanitized at extraction, but composing them (version "." packaging) + // could still produce ".." at a boundary (e.g. a trailing-dot version "1.0." -> "app-1.0..jar"). + // Reusing validateMavenCoordinate keeps the traversal rules in a single place. An invalid + // composed name means we skip the main artifact rather than fail the whole build. + if validateErr := validateMavenCoordinate(mainArtifactName); validateErr != nil { + log.Warn("Skipping main artifact for module " + moduleId + ": " + validateErr.Error()) + } else { + // filepath.Base strips any directory component, guaranteeing the artifact is read from directly + // within targetDir regardless of the (already validated) input. This collapses the value to a + // single path element and is the canonical sanitizer for stored path-traversal data flows. + mainArtifactName = filepath.Base(mainArtifactName) + mainArtifactPath := filepath.Join(targetDir, mainArtifactName) + + if _, statErr := os.Stat(mainArtifactPath); statErr == nil { + artifacts = append(artifacts, createArtifactFromFile(mainArtifactPath, groupId, artifactId, version, packagingType)) + } + } } - return nil + // POM artifact (from the module root, not target). + pomArtifactPath := filepath.Join(location.Dir, "pom.xml") + if _, statErr := os.Stat(pomArtifactPath); statErr == nil { + artifacts = append(artifacts, createArtifactFromFile(pomArtifactPath, groupId, artifactId, version, "pom")) + } + + return artifacts } -// getPackagingType extracts packaging type from pom.xml -func getPackagingType(workingDir string) string { - pomPath := filepath.Join(workingDir, "pom.xml") - pomData, err := os.ReadFile(pomPath) - if err != nil { - return "jar" // Default to jar +// splitModuleId splits a "groupId:artifactId:version" module id into its parts and validates each as a +// Maven coordinate (the parts are later composed into filenames, so path-traversal input is rejected). +func splitModuleId(moduleId string) (groupId, artifactId, version string, ok bool) { + parts := strings.Split(moduleId, ":") + if len(parts) != 3 { + return "", "", "", false } - - var pom PomProject - if err := xml.Unmarshal(pomData, &pom); err != nil { - return "jar" + for _, p := range parts { + if validateMavenCoordinate(p) != nil { + return "", "", "", false + } } + return parts[0], parts[1], parts[2], true +} - if pom.Packaging == "" { - return "jar" // Maven default +// sanitizePackaging validates a packaging value (recorded from the dependency-tree root node) that is +// composed into an artifact filename, falling back to Maven's default "jar" when empty or unsafe. +func sanitizePackaging(packaging string) string { + if packaging == "" { + return "jar" } - - // Sanitize against path traversal: the packaging type from pom.xml is user-controlled and is - // later used to build a filename joined with the target directory. Valid packaging types are - // short tokens (jar, war, pom, ear, bundle, ...) that never contain path separators or "..". - // On an invalid value we fall back to "jar" (Maven's default) rather than failing the build. - if err := validateMavenCoordinate(pom.Packaging); err != nil { - log.Warn("Invalid packaging type in pom.xml, falling back to jar: " + err.Error()) + if validateMavenCoordinate(packaging) != nil { + log.Warn("Invalid packaging '" + packaging + "', falling back to jar") return "jar" } - - return pom.Packaging + return packaging } // createArtifactFromFile creates an entities.Artifact from a file path diff --git a/artifactory/commands/flexpack/maven_test.go b/artifactory/commands/flexpack/maven_test.go index 79ac34a4..9c4828c3 100644 --- a/artifactory/commands/flexpack/maven_test.go +++ b/artifactory/commands/flexpack/maven_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" + "github.com/jfrog/build-info-go/entities" + bidflexpack "github.com/jfrog/build-info-go/flexpack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -59,116 +61,212 @@ func TestValidateMavenCoordinate(t *testing.T) { } } -// writePom is a test helper that creates a pom.xml inside a fresh temp dir and returns the dir. -func writePom(t *testing.T, contents string) string { - t.Helper() - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(contents), 0600)) - return dir +func TestExtractResolutionArgs(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + {name: "empty", args: nil, want: nil}, + {name: "goals only dropped", args: []string{"clean", "deploy"}, want: nil}, + {name: "profiles kept", args: []string{"deploy", "-Pprod,fast"}, want: []string{"-Pprod,fast"}}, + {name: "properties kept", args: []string{"install", "-DskipTests", "-Drevision=1.2.3"}, want: []string{"-DskipTests", "-Drevision=1.2.3"}}, + {name: "settings with separate value", args: []string{"deploy", "-s", "custom.xml"}, want: []string{"-s", "custom.xml"}}, + {name: "long settings with separate value", args: []string{"deploy", "--settings", "custom.xml"}, want: []string{"--settings", "custom.xml"}}, + {name: "settings attached form", args: []string{"deploy", "--settings=custom.xml"}, want: []string{"--settings=custom.xml"}}, + {name: "settings flag at end without value", args: []string{"deploy", "-s"}, want: []string{"-s"}}, + {name: "mixed", args: []string{"clean", "deploy", "-Pprod", "-s", "s.xml", "-Dfoo=bar", "-X"}, want: []string{"-Pprod", "-s", "s.xml", "-Dfoo=bar"}}, + {name: "alternate POM with separate value", args: []string{"deploy", "-f", "module/pom.xml"}, want: []string{"-f", "module/pom.xml"}}, + {name: "alternate POM attached form", args: []string{"deploy", "--file=module/pom.xml"}, want: []string{"--file=module/pom.xml"}}, + {name: "global settings separate value", args: []string{"deploy", "-gs", "global.xml"}, want: []string{"-gs", "global.xml"}}, + {name: "global settings attached form", args: []string{"deploy", "--global-settings=global.xml"}, want: []string{"--global-settings=global.xml"}}, + {name: "offline flag short", args: []string{"install", "-o"}, want: []string{"-o"}}, + {name: "offline flag long", args: []string{"install", "--offline"}, want: []string{"--offline"}}, + {name: "activate-profiles long form", args: []string{"deploy", "--activate-profiles", "prod,ci"}, want: []string{"--activate-profiles", "prod,ci"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractResolutionArgs(tt.args)) + }) + } } -// TestGetMavenArtifactCoordinates_Valid verifies coordinates are extracted from a well-formed pom.xml, -// including the parent fallback for groupId/version. -func TestGetMavenArtifactCoordinates_Valid(t *testing.T) { - t.Run("explicit coordinates", func(t *testing.T) { - dir := writePom(t, ` - - com.example - my-app - 1.2.3 -`) - groupId, artifactId, version, err := getMavenArtifactCoordinates(dir) - require.NoError(t, err) - assert.Equal(t, "com.example", groupId) - assert.Equal(t, "my-app", artifactId) - assert.Equal(t, "1.2.3", version) - }) - - t.Run("parent fallback", func(t *testing.T) { - dir := writePom(t, ` - - - com.parent - 9.9.9 - - child-app -`) - groupId, artifactId, version, err := getMavenArtifactCoordinates(dir) - require.NoError(t, err) - assert.Equal(t, "com.parent", groupId) - assert.Equal(t, "child-app", artifactId) - assert.Equal(t, "9.9.9", version) - }) +func TestSplitModuleId(t *testing.T) { + tests := []struct { + name string + id string + wantOk bool + wantG, wantA, wantV string + }{ + {name: "valid", id: "com.example:app:1.0.0", wantOk: true, wantG: "com.example", wantA: "app", wantV: "1.0.0"}, + {name: "too few parts", id: "com.example:app", wantOk: false}, + {name: "too many parts", id: "com.example:app:1.0:extra", wantOk: false}, + {name: "path traversal in version", id: "com.example:app:../../etc", wantOk: false}, + {name: "empty part", id: "com.example::1.0.0", wantOk: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, a, v, ok := splitModuleId(tt.id) + assert.Equal(t, tt.wantOk, ok) + if tt.wantOk { + assert.Equal(t, tt.wantG, g) + assert.Equal(t, tt.wantA, a) + assert.Equal(t, tt.wantV, v) + } + }) + } } -// TestGetMavenArtifactCoordinates_PathTraversal ensures a crafted pom.xml cannot inject traversal -// sequences through any coordinate field. -func TestGetMavenArtifactCoordinates_PathTraversal(t *testing.T) { +func TestSanitizePackaging(t *testing.T) { tests := []struct { - name string - pom string + name string + packaging string + want string }{ - { - name: "malicious groupId", - pom: ` - ../../../../etc - app - 1.0.0 -`, - }, - { - name: "malicious artifactId", - pom: ` - com.example - ../evil - 1.0.0 -`, - }, - { - name: "malicious version", - pom: ` - com.example - app - ../../1.0 -`, - }, - { - name: "separator in artifactId", - pom: ` - com.example - a/b - 1.0.0 -`, - }, + {name: "empty defaults to jar", packaging: "", want: "jar"}, + {name: "jar", packaging: "jar", want: "jar"}, + {name: "war", packaging: "war", want: "war"}, + {name: "path traversal falls back", packaging: "../evil", want: "jar"}, + {name: "slash falls back", packaging: "a/b", want: "jar"}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dir := writePom(t, tt.pom) - _, _, _, err := getMavenArtifactCoordinates(dir) - assert.Error(t, err, "crafted pom.xml should be rejected") + assert.Equal(t, tt.want, sanitizePackaging(tt.packaging)) }) } } -// TestGetPackagingType covers the default, valid, and sanitized-fallback paths. -func TestGetPackagingType(t *testing.T) { - t.Run("explicit packaging", func(t *testing.T) { - dir := writePom(t, `war`) - assert.Equal(t, "war", getPackagingType(dir)) +// writeFile creates a file (and parents) with the given content, for artifact fixtures. +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) +} + +func TestCollectModuleArtifacts(t *testing.T) { + t.Run("jar module gets jar + pom", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pom.xml"), "") + writeFile(t, filepath.Join(dir, "target", "app-1.0.0.jar"), "jar-bytes") + + got := collectModuleArtifacts("com.example:app:1.0.0", bidflexpack.ModuleLocation{Dir: dir, Packaging: "jar"}) + + require.Len(t, got, 2) + names := []string{got[0].Name, got[1].Name} + assert.Contains(t, names, "app-1.0.0.jar") + assert.Contains(t, names, "app-1.0.0.pom") + for _, a := range got { + assert.NotEmpty(t, a.Sha256, "sha256 computed for %s", a.Name) + } }) - t.Run("defaults to jar when missing", func(t *testing.T) { - dir := writePom(t, ``) - assert.Equal(t, "jar", getPackagingType(dir)) + t.Run("pom aggregator (no target) yields pom only", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pom.xml"), "") + + got := collectModuleArtifacts("com.example:parent:1.0.0", bidflexpack.ModuleLocation{Dir: dir, Packaging: "pom"}) + + require.Len(t, got, 1) + assert.Equal(t, "parent-1.0.0.pom", got[0].Name) + assert.Equal(t, "pom", got[0].Type) }) - t.Run("defaults to jar when no pom", func(t *testing.T) { - assert.Equal(t, "jar", getPackagingType(t.TempDir())) + t.Run("invalid module id returns nothing", func(t *testing.T) { + assert.Nil(t, collectModuleArtifacts("bad-id", bidflexpack.ModuleLocation{Dir: t.TempDir(), Packaging: "jar"})) }) - t.Run("falls back to jar on path traversal", func(t *testing.T) { - dir := writePom(t, `../../bin/sh`) - assert.Equal(t, "jar", getPackagingType(dir)) + t.Run("war packaging picks war not jar", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "pom.xml"), "") + writeFile(t, filepath.Join(dir, "target", "web-2.0.war"), "war-bytes") + writeFile(t, filepath.Join(dir, "target", "web-2.0.jar"), "intermediate-jar") // must be excluded + + got := collectModuleArtifacts("com.example:web:2.0", bidflexpack.ModuleLocation{Dir: dir, Packaging: "war"}) + + var names []string + for _, a := range got { + names = append(names, a.Name) + } + assert.Contains(t, names, "web-2.0.war") + assert.NotContains(t, names, "web-2.0.jar") + }) +} + +func TestAddDeployedArtifactsToBuildInfo(t *testing.T) { + libDir := t.TempDir() + writeFile(t, filepath.Join(libDir, "pom.xml"), "") + writeFile(t, filepath.Join(libDir, "target", "lib-1.0.0.jar"), "lib-jar") + + appDir := t.TempDir() + writeFile(t, filepath.Join(appDir, "pom.xml"), "") + writeFile(t, filepath.Join(appDir, "target", "app-1.0.0.jar"), "app-jar") + + buildInfo := &entities.BuildInfo{Modules: []entities.Module{ + {Id: "com.example:lib:1.0.0", Type: entities.Maven}, + {Id: "com.example:app:1.0.0", Type: entities.Maven}, + {Id: "com.example:ghost:1.0.0", Type: entities.Maven}, // no location -> skipped, no artifacts + }} + locations := map[string]bidflexpack.ModuleLocation{ + "com.example:lib:1.0.0": {Dir: libDir, Packaging: "jar"}, + "com.example:app:1.0.0": {Dir: appDir, Packaging: "jar"}, + } + + addDeployedArtifactsToBuildInfo(buildInfo, locations) + + // Each located module gets its OWN artifacts (jar + pom), not dumped into Modules[0]. + assert.Len(t, buildInfo.Modules[0].Artifacts, 2) + assert.Len(t, buildInfo.Modules[1].Artifacts, 2) + assert.Empty(t, buildInfo.Modules[2].Artifacts, "module without a location gets no artifacts") + + // Verify attribution is per-module (lib's jar on lib, app's jar on app). + assert.Equal(t, "lib-1.0.0.jar", firstJar(buildInfo.Modules[0].Artifacts)) + assert.Equal(t, "app-1.0.0.jar", firstJar(buildInfo.Modules[1].Artifacts)) +} + +func firstJar(artifacts []entities.Artifact) string { + for _, a := range artifacts { + if a.Type == "jar" { + return a.Name + } + } + return "" +} + +func TestChecksumAql(t *testing.T) { + t.Run("single checksum scoped to repo", func(t *testing.T) { + assert.JSONEq(t, + `{"repo":"my-local","$or":[{"sha256":"abc"}]}`, + checksumAql("my-local", []string{"abc"})) }) + t.Run("multiple checksums", func(t *testing.T) { + assert.JSONEq(t, + `{"repo":"my-local","$or":[{"sha256":"abc"},{"sha256":"def"}]}`, + checksumAql("my-local", []string{"abc", "def"})) + }) +} + +func TestExtractRepoKeyFromUrl(t *testing.T) { + tests := []struct { + name string + url string + want string + wantErr bool + }{ + {name: "standard", url: "https://acme.jfrog.io/artifactory/maven-local", want: "maven-local"}, + {name: "trailing slash", url: "https://acme.jfrog.io/artifactory/maven-local/", want: "maven-local"}, + {name: "api/maven form", url: "https://acme.jfrog.io/artifactory/api/maven/maven-virtual", want: "maven-virtual"}, + {name: "host-root single segment", url: "https://artifactory.acme.com/maven-local", want: "maven-local"}, + {name: "empty", url: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractRepoKeyFromUrl(tt.url) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } } diff --git a/artifactory/commands/mvn/mvn.go b/artifactory/commands/mvn/mvn.go index 904521e8..6df5a3f3 100644 --- a/artifactory/commands/mvn/mvn.go +++ b/artifactory/commands/mvn/mvn.go @@ -192,6 +192,7 @@ func (mc *MvnCommand) Run() error { SetConfigPath(mc.configPath). SetGoals(mc.goals). SetBuildConf(mc.configuration). + SetServerDetails(mc.serverDetails). SetPreferWrapper(mc.preferWrapper) return RunMvn(mvnParams) } diff --git a/artifactory/commands/mvn/utils.go b/artifactory/commands/mvn/utils.go index eb9cd725..c180178a 100644 --- a/artifactory/commands/mvn/utils.go +++ b/artifactory/commands/mvn/utils.go @@ -1,6 +1,7 @@ package mvn import ( + "encoding/json" "io" "os" "os/exec" @@ -8,6 +9,7 @@ import ( "strings" "github.com/jfrog/build-info-go/build" + "github.com/jfrog/build-info-go/entities" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/flexpack" "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils/civcs" @@ -35,6 +37,9 @@ type MvnUtils struct { disableDeploy bool outputWriter io.Writer preferWrapper bool + // serverDetails is the resolved server (from --server-id, else default) used for native build-info + // collection - property tagging, virtual-repo resolution and repository lookups. + serverDetails *config.ServerDetails } func NewMvnUtils() *MvnUtils { @@ -86,39 +91,40 @@ func (mu *MvnUtils) SetOutputWriter(writer io.Writer) *MvnUtils { return mu } +func (mu *MvnUtils) SetServerDetails(serverDetails *config.ServerDetails) *MvnUtils { + mu.serverDetails = serverDetails + return mu +} + // SetPreferWrapper controls Maven executable resolution in native (FlexPack) mode. -// When true (jf mvnw), a Maven Wrapper (mvnw/mvnw.cmd) must be found upward from the -// working directory, or the command fails. When false (jf mvn), a wrapper is never -// used; native mode always runs "mvn" from PATH. +// When true (jf mvnw), a Maven Wrapper (mvnw/mvnw.cmd) must be present in the working +// directory or a parent; the command fails rather than falling back to PATH "mvn". +// When false (jf mvn), native mode always uses "mvn" from PATH. func (mu *MvnUtils) SetPreferWrapper(preferWrapper bool) *MvnUtils { mu.preferWrapper = preferWrapper return mu } // resolveMavenExecutable determines which Maven executable native (FlexPack) mode should run. -// jf mvn (preferWrapper=false) always uses "mvn" from PATH, unchanged from prior behavior. -// jf mvnw (preferWrapper=true) searches upward from the working directory for a project root -// containing ".mvn" (the Maven Wrapper marker directory) and requires mvnw/mvnw.cmd there; +// jf mvn (preferWrapper=false) always uses "mvn" from PATH. +// jf mvnw (preferWrapper=true) searches upward for the wrapper script itself (mvnw/mvnw.cmd); // it fails rather than silently falling back to PATH "mvn". func resolveMavenExecutable(preferWrapper bool) (string, error) { if !preferWrapper { return "mvn", nil } - projectRoot, exists, err := fileutils.FindUpstream(".mvn", fileutils.Dir) + wrapperName := "mvnw" + if coreutils.IsWindows() { + wrapperName = "mvnw.cmd" + } + wrapperDir, exists, err := fileutils.FindUpstream(wrapperName, fileutils.Any) if err != nil { return "", errorutils.CheckError(err) } if exists { - wrapperName := "mvnw" - if coreutils.IsWindows() { - wrapperName = "mvnw.cmd" - } - wrapperPath := filepath.Join(projectRoot, wrapperName) - if _, statErr := os.Stat(wrapperPath); statErr == nil { - return wrapperPath, nil - } + return filepath.Join(wrapperDir, wrapperName), nil } - return "", errorutils.CheckErrorf("mvnw invoked but no Maven Wrapper (mvnw/mvnw.cmd) was found in the current directory or any parent directory") + return "", errorutils.CheckErrorf("mvnw invoked but no Maven Wrapper (%s) was found in the current directory or any parent directory", wrapperName) } func RunMvn(mu *MvnUtils) error { @@ -162,8 +168,9 @@ func RunMvn(mu *MvnUtils) error { return errorutils.CheckError(err) } - // Use FlexPack to collect Maven build info - err = flexpack.CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber, mu.buildConf) + // Use FlexPack to collect Maven build info. The user's goals/flags are forwarded so the + // internal dependency resolution matches the profiles/settings the build ran with. + err = flexpack.CollectMavenBuildInfoWithFlexPack(workingDir, buildName, buildNumber, mu.buildConf, mu.goals, mu.serverDetails) if err != nil { return errorutils.CheckError(err) } @@ -226,9 +233,50 @@ func RunMvn(mu *MvnUtils) error { return err } mu.buildInfoFilePath = mavenModule.GetGeneratedBuildInfoPath() + // Mark the legacy build-info-extractor path so the published JSON is distinguishable from native + // FlexPack (which stamps the same property with "native"). Best-effort: never fail the build for it. + stampMavenBuildMode(mu.buildInfoFilePath, entities.MavenBuildModeLegacy) return nil } +// stampMavenBuildMode injects the Maven build-mode marker (entities.MavenBuildModeProperty) into a +// build-info JSON file generated by the legacy extractor, matching what the native FlexPack collector +// records in-process. It edits the raw JSON object so every field the extractor wrote is preserved +// verbatim. The marker is informational, so any failure is logged at debug level and ignored. +func stampMavenBuildMode(buildInfoPath, mode string) { + if buildInfoPath == "" { + return + } + content, err := os.ReadFile(buildInfoPath) + if err != nil { + log.Debug("Skipping maven build-mode stamp, could not read build info: " + err.Error()) + return + } + var raw map[string]interface{} + if err = json.Unmarshal(content, &raw); err != nil { + log.Debug("Skipping maven build-mode stamp, could not parse build info: " + err.Error()) + return + } + props, ok := raw["properties"].(map[string]interface{}) + if !ok || props == nil { + props = map[string]interface{}{} + raw["properties"] = props + } + props[entities.MavenBuildModeProperty] = mode + updated, err := json.Marshal(raw) + if err != nil { + log.Debug("Skipping maven build-mode stamp, could not serialize build info: " + err.Error()) + return + } + perm := os.FileMode(0644) + if info, statErr := os.Stat(buildInfoPath); statErr == nil { + perm = info.Mode().Perm() + } + if err = os.WriteFile(buildInfoPath, updated, perm); err != nil { + log.Debug("Skipping maven build-mode stamp, could not write build info: " + err.Error()) + } +} + // GetBuildInfoFilePath returns the path to the temporary build info file // This file stores build-info details and is populated by the Maven extractor after CalcDependencies() is called func (mu *MvnUtils) GetBuildInfoFilePath() string { diff --git a/go.mod b/go.mod index 1190623f..1e4395e6 100644 --- a/go.mod +++ b/go.mod @@ -203,3 +203,5 @@ require ( // replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab // replace github.com/ktrysmt/go-bitbucket => github.com/ktrysmt/go-bitbucket v0.9.80 + +replace github.com/jfrog/build-info-go => github.com/jfrog/build-info-go v1.13.1-0.20260804091857-2e9f7c89c1d5 diff --git a/go.sum b/go.sum index 558ecfca..c7b76fa1 100644 --- a/go.sum +++ b/go.sum @@ -378,8 +378,8 @@ 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.20260804091857-2e9f7c89c1d5 h1:CU8GecwasJYgdQa+bnSgOeKh2jmH4VNUwg2ch+fz3ao= +github.com/jfrog/build-info-go v1.13.1-0.20260804091857-2e9f7c89c1d5/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=