Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions build/utils/dotnet/dependencies/assetsjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
buildinfo "github.com/jfrog/build-info-go/entities"
"github.com/jfrog/build-info-go/utils"
"github.com/jfrog/gofrog/crypto"
"os"
"path/filepath"
"sort"
"strings"

buildinfo "github.com/jfrog/build-info-go/entities"
"github.com/jfrog/build-info-go/utils"
"github.com/jfrog/gofrog/crypto"
)

const (
Expand Down Expand Up @@ -65,28 +67,46 @@ func (extractor *assetsExtractor) new(dependenciesSource string, log utils.Log)
}

func (assets *assets) getChildrenMap() map[string][]string {
dependenciesRelations := map[string][]string{}
// Use a set to deduplicate children across multiple target frameworks
dependenciesRelations := map[string]map[string]struct{}{}
for _, dependencies := range assets.Targets {
for dependencyId, targetDependencies := range dependencies {
var transitive []string
dependencyName := getDependencyName(dependencyId)
if _, ok := dependenciesRelations[dependencyName]; !ok {
dependenciesRelations[dependencyName] = map[string]struct{}{}
}
for transitiveName := range targetDependencies.Dependencies {
transitive = append(transitive, strings.ToLower(transitiveName))
dependenciesRelations[dependencyName][strings.ToLower(transitiveName)] = struct{}{}
}
dependencyName := getDependencyName(dependencyId)
dependenciesRelations[dependencyName] = transitive
}
}
return dependenciesRelations
// Convert sets to sorted slices for deterministic output
result := make(map[string][]string, len(dependenciesRelations))
for dependencyName, transitiveSet := range dependenciesRelations {
result[dependencyName] = setToSortedSlice(transitiveSet)
}
return result
}

func setToSortedSlice(values map[string]struct{}) []string {
sortedValues := make([]string, 0, len(values))
for value := range values {
sortedValues = append(sortedValues, value)
}
sort.Strings(sortedValues)
return sortedValues
}

func (assets *assets) getDirectDependencies() []string {
var directDependencies []string
// Use a set to deduplicate across multiple target frameworks
directDependencies := map[string]struct{}{}
for _, framework := range assets.Project.Frameworks {
for dependencyName := range framework.Dependencies {
directDependencies = append(directDependencies, strings.ToLower(dependencyName))
directDependencies[strings.ToLower(dependencyName)] = struct{}{}
}
}
return directDependencies
// Return sorted slice for deterministic output
return setToSortedSlice(directDependencies)
}

func (assets *assets) getAllDependencies(log utils.Log) (map[string]*buildinfo.Dependency, error) {
Expand Down
105 changes: 105 additions & 0 deletions build/utils/dotnet/dependencies/assetsjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,108 @@ func TestGetDependencyIdForBuildInfo(t *testing.T) {
assert.Equal(t, expected[index], actualId)
}
}

func TestGetDirectDependenciesDeterministic(t *testing.T) {
// Test that direct dependencies are returned in sorted order
content := []byte(`{
"version": 3,
"targets": {},
"project": {
"restore": {"packagesPath": "unused"},
"frameworks": {
"net8.0": {
"dependencies": {
"Zebra": {"target": "Package", "version": "1.0.0"},
"Alpha": {"target": "Package", "version": "1.0.0"},
"Middle": {"target": "Package", "version": "1.0.0"}
}
}
}
}
}`)

var assetsObj assets
assert.NoError(t, json.Unmarshal(content, &assetsObj))

// Run multiple times to verify consistency
expected := []string{"alpha", "middle", "zebra"}
for i := 0; i < 10; i++ {
result := assetsObj.getDirectDependencies()
assert.Equal(t, expected, result, "Run %d produced different order", i)
}
}

func TestGetChildrenMapDeterministic(t *testing.T) {
// Test that children map returns sorted children across multiple target frameworks
content := []byte(`{
"version": 3,
"targets": {
".NETCoreApp,Version=v8.0": {
"Parent/1.0.0": {
"dependencies": {
"Zebra": "1.0.0",
"Alpha": "1.0.0"
}
}
},
".NETCoreApp,Version=v7.0": {
"Parent/1.0.0": {
"dependencies": {
"Middle": "1.0.0",
"Alpha": "1.0.0"
}
}
}
},
"project": {
"restore": {"packagesPath": "unused"},
"frameworks": {}
}
}`)

var assetsObj assets
assert.NoError(t, json.Unmarshal(content, &assetsObj))

// Run multiple times to verify consistency
// Alpha appears in both frameworks (should be deduplicated)
expected := []string{"alpha", "middle", "zebra"}
for i := 0; i < 10; i++ {
result := assetsObj.getChildrenMap()
assert.Equal(t, expected, result["parent"], "Run %d produced different order", i)
}
}

func TestSetToSortedSlice(t *testing.T) {
tests := []struct {
name string
input map[string]struct{}
expected []string
}{
{
name: "empty map",
input: map[string]struct{}{},
expected: []string{},
},
{
name: "single element",
input: map[string]struct{}{"alpha": {}},
expected: []string{"alpha"},
},
{
name: "multiple elements sorted",
input: map[string]struct{}{
"zebra": {},
"alpha": {},
"middle": {},
},
expected: []string{"alpha", "middle", "zebra"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := setToSortedSlice(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
66 changes: 57 additions & 9 deletions build/utils/dotnet/solution/solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/jfrog/build-info-go/build/utils/dotnet/dependencies"
Expand Down Expand Up @@ -72,20 +73,35 @@ func (solution *solution) BuildInfo(moduleName string, log utils.Log) (*buildinf
module := buildinfo.Module{Id: getModuleId(moduleName, currProject.Name()), Type: buildinfo.Nuget}

// Populate requestedBy field
// Seed all direct dependencies with the module path
for _, directDepName := range directDeps {
if directDep, exist := projectDependencies[directDepName]; exist {
// Add the direct path (don't overwrite - merge with existing)
directDep.RequestedBy = append(directDep.RequestedBy, []string{module.Id})
}
}
// Propagate paths to transitive dependencies
for _, directDepName := range directDeps {
// Populate the direct dependency requested by only if the dependency exist in the cache
if directDep, exist := projectDependencies[directDepName]; exist {
directDep.RequestedBy = [][]string{{module.Id}}
populateRequestedBy(*directDep, projectDependencies, childrenMap)
}
}

// Populate module dependencies
for _, dep := range projectDependencies {
// Sort dependency keys for deterministic output
depKeys := make([]string, 0, len(projectDependencies))
for key := range projectDependencies {
depKeys = append(depKeys, key)
}
sort.Strings(depKeys)

for _, key := range depKeys {
dep := projectDependencies[key]
// If dependency has no RequestedBy field, it means that the dependency not accessible in the current project.
// In that case, the dependency is assumed to be under a project which is referenced by this project.
// We therefore don't include the dependency in the build-info.
if len(dep.RequestedBy) > 0 {
sortRequestedByPaths(dep.RequestedBy)
module.Dependencies = append(module.Dependencies, *dep)
}
}
Expand Down Expand Up @@ -128,6 +144,40 @@ func getDependencyName(dependencyKey string) string {
return strings.ToLower(dependencyName)
}

// sortRequestedByPaths sorts RequestedBy paths for deterministic output.
// Shorter paths come first (direct deps before transitive), then lexicographic order.
func sortRequestedByPaths(paths [][]string) {
sort.Slice(paths, func(i, j int) bool {
// Shorter paths come first
if len(paths[i]) != len(paths[j]) {
return len(paths[i]) < len(paths[j])
}
// Same length: compare lexicographically
for k := 0; k < len(paths[i]); k++ {
if paths[i][k] != paths[j][k] {
return paths[i][k] < paths[j][k]
}
}
return false
})
}

// isMatchingDependencySource checks if a dependency source file belongs to a project.
// It matches if the source is:
// - directly in the project root directory
// - under the project's obj directory (for project.assets.json)
// - in a subdirectory named after the project
func isMatchingDependencySource(source, projectRootPath, projectObjPattern, projectNamePattern string) bool {
sourceLower := strings.ToLower(source)
// Check if source is directly in project root
isInRoot := projectRootPath == strings.ToLower(filepath.Dir(source))
// Check if source is under the project's obj directory
isUnderObjDir := strings.Contains(sourceLower, projectObjPattern)
// Check if source path contains the project name directory (handles subdirs like /projectname/obj/)
isUnderSubDirWithName := strings.Contains(sourceLower, projectNamePattern)
return isInRoot || isUnderObjDir || isUnderSubDirWithName
}

func (solution *solution) Marshal() ([]byte, error) {
return json.Marshal(&struct {
Projects []project.Project `json:"projects,omitempty"`
Expand Down Expand Up @@ -233,14 +283,12 @@ func (solution *solution) loadSingleProject(project project.Project, log utils.L
// It can be located directly in the project's root directory or in a directory with the project name under the solution root
// or under obj directory (in case of assets.json file)
projectRootPath := strings.ToLower(project.RootPath())
projectPathPattern := strings.ToLower(filepath.Join(projectRootPath, dependencies.AssetDirName) + string(filepath.Separator))
projectNamePattern := strings.ToLower(string(filepath.Separator) + project.Name())
projectObjPattern := strings.ToLower(filepath.Join(projectRootPath, dependencies.AssetDirName) + string(filepath.Separator))
// Pattern includes trailing separator to avoid partial matches (e.g., "project" matching "projectname")
projectNamePattern := strings.ToLower(string(filepath.Separator) + project.Name() + string(filepath.Separator))
var dependenciesSource string
for _, source := range solution.dependenciesSources {
isInRoot := projectRootPath == strings.ToLower(filepath.Dir(source))
isUnderObjDir := strings.Contains(strings.ToLower(source), projectPathPattern)
isUnderSubDirWithName := strings.HasSuffix(strings.ToLower(filepath.Dir(source)), projectNamePattern)
if isInRoot || isUnderObjDir || isUnderSubDirWithName {
if isMatchingDependencySource(source, projectRootPath, projectObjPattern, projectNamePattern) {
dependenciesSource = source
break
}
Expand Down
Loading
Loading