diff --git a/docs/md/melange_license-check.md b/docs/md/melange_license-check.md index 55806cc13..7f471acb5 100644 --- a/docs/md/melange_license-check.md +++ b/docs/md/melange_license-check.md @@ -30,6 +30,7 @@ melange license-check file [flags] ``` --fix fix license issues in the melange yaml file -h, --help help for license-check + --structured enable structured license grouping by directory while fixing license issues --workdir string path to the working directory, e.g. where the source will be extracted to ``` diff --git a/examples/complex-licensing.yaml b/examples/complex-licensing.yaml new file mode 100644 index 000000000..92a0784a8 --- /dev/null +++ b/examples/complex-licensing.yaml @@ -0,0 +1,43 @@ +package: + name: complex-licensing-example + version: 1.0.0 + epoch: 0 + description: "Example package demonstrating complex licensing scenarios" + copyright: + # Simple license case (backward compatible) + - license: MIT + license-path: LICENSE-MIT + + # Multi-licensed main code (user can choose either license) + - operator: OR + licenses: + - license: Apache-2.0 + license-path: LICENSE-Apache + - license: GPL-3.0-or-later + license-path: LICENSE-GPL + + # Vendored code with additional requirements (must comply with all) + - operator: AND + licenses: + # Main project has multiple license options + - operator: OR + licenses: + - license: MIT + - license: BSD-3-Clause + # Plus vendored GPL code that must be included + - license: LGPL-2.1-or-later + paths: ["vendor/lgpl-lib/*"] + license-path: vendor/lgpl-lib/COPYING + +environment: + contents: + repositories: + - https://packages.wolfi.dev/os + keyring: + - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub + packages: + - build-base + - busybox + +pipeline: + - runs: echo "Complex licensing example" \ No newline at end of file diff --git a/pkg/cli/license_check.go b/pkg/cli/license_check.go index 9681720de..85686f59a 100644 --- a/pkg/cli/license_check.go +++ b/pkg/cli/license_check.go @@ -32,6 +32,7 @@ import ( func licenseCheck() *cobra.Command { var workDir string var fix bool + var structured bool cmd := &cobra.Command{ Use: "license-check file", Short: "Gather and check licensing data", @@ -81,6 +82,7 @@ func licenseCheck() *cobra.Command { ctx, copyright.WithLicenses(detectedLicenses), copyright.WithDiffs(diffs), + copyright.WithStructured(structured), ) err = rc.Renovate(cmd.Context(), copyrightRenovator) } @@ -91,6 +93,7 @@ func licenseCheck() *cobra.Command { cmd.Flags().StringVar(&workDir, "workdir", "", "path to the working directory, e.g. where the source will be extracted to") cmd.Flags().BoolVar(&fix, "fix", false, "fix license issues in the melange yaml file") + cmd.Flags().BoolVar(&structured, "structured", false, "enable structured license grouping by directory while fixing license issues") return cmd } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9e5f2d57c..5d5f5fa0a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -413,28 +413,153 @@ type Copyright struct { Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"` // Optional: Attestations of the license Attestation string `json:"attestation,omitempty" yaml:"attestation,omitempty"` - // Required: The license for this package - License string `json:"license" yaml:"license"` + // Optional: The license for this package (for simple license entries) + License string `json:"license,omitempty" yaml:"license,omitempty"` // Optional: Path to text of the custom License Ref LicensePath string `json:"license-path,omitempty" yaml:"license-path,omitempty"` // Optional: License override DetectionOverride string `json:"detection-override,omitempty" yaml:"detection-override,omitempty"` + + // License grouping fields (for complex license structures) + // Optional: Operator to combine licenses in this group ("AND" or "OR") + // If specified, this becomes a grouping entry and "licenses" field should be used + Operator string `json:"operator,omitempty" yaml:"operator,omitempty"` + // Optional: List of sub-licenses/groups (only used when Operator is specified) + Licenses []Copyright `json:"licenses,omitempty" yaml:"licenses,omitempty"` } // LicenseExpression returns an SPDX license expression formed from the data in -// the copyright structs found in the conf. It's a simple OR for now. +// the copyright structs found in the conf. Supports hierarchical license grouping +// with AND/OR operators for complex licensing scenarios. func (p Package) LicenseExpression() string { - licenseExpression := "" if p.Copyright == nil { - return licenseExpression + return "" } - for _, cp := range p.Copyright { - if licenseExpression != "" { - licenseExpression += " OR " + return buildLicenseExpression(p.Copyright) +} + +// buildLicenseExpression recursively builds an SPDX license expression from copyright entries +func buildLicenseExpression(copyrights []Copyright) string { + if len(copyrights) == 0 { + return "" + } + var expressions []string + for _, cp := range copyrights { + expr := buildSingleLicenseExpression(cp, len(copyrights) > 1) + if expr != "" { + expressions = append(expressions, expr) + } + } + if len(expressions) == 0 { + return "" + } + if len(expressions) == 1 { + return expressions[0] + } + + // Determine the operator to use when combining multiple top-level entries + // Default to AND unless all copyright entries are grouping entries with the same operator + defaultOperator := "AND" + var commonOperator string + allGroupingsWithSameOperator := true + for _, cp := range copyrights { + // Check if this is a grouping entry + if cp.Operator != "" && len(cp.Licenses) > 0 { + if commonOperator == "" { + commonOperator = cp.Operator + } else if commonOperator != cp.Operator { + allGroupingsWithSameOperator = false + break + } + } else { + // Simple license entry - can't use grouping operator + allGroupingsWithSameOperator = false + break + } + } + + // Only use the common operator if ALL entries are groupings with the same operator + if allGroupingsWithSameOperator && commonOperator != "" { + defaultOperator = commonOperator + } + + return combineExpressions(expressions, defaultOperator) +} + +// buildSingleLicenseExpression builds expression for a single copyright entry +func buildSingleLicenseExpression(cp Copyright, needsParentheses bool) string { + // If this is a grouping entry (has Operator and Licenses) + if cp.Operator != "" && len(cp.Licenses) > 0 { + var subExpressions []string + for _, subCp := range cp.Licenses { + expr := buildSingleLicenseExpression(subCp, true) // Nested expressions always need parentheses when grouped + if expr != "" { + subExpressions = append(subExpressions, expr) + } + } + + if len(subExpressions) == 0 { + return "" + } + if len(subExpressions) == 1 { + return subExpressions[0] + } + + combined := combineExpressions(subExpressions, cp.Operator) + // Only wrap in parentheses if this is being used in a larger expression + if needsParentheses { + return "(" + combined + ")" + } + return combined + } + + // Simple license entry + if cp.License != "" { + return cp.License + } + + return "" +} + +// combineExpressions combines license expressions with the specified operator +// and removes duplicates to ensure clean license expressions +func combineExpressions(expressions []string, operator string) string { + if len(expressions) == 0 { + return "" + } + // Remove duplicates while preserving order + deduped := deduplicateExpressions(expressions) + if len(deduped) == 1 { + return deduped[0] + } + // Default to AND if operator is not specified or invalid + if operator != "AND" && operator != "OR" { + operator = "AND" + } + + return strings.Join(deduped, " "+operator+" ") +} + +// deduplicateExpressions removes duplicate license expressions while preserving order +func deduplicateExpressions(expressions []string) []string { + if len(expressions) <= 1 { + return expressions + } + seen := make(map[string]bool) + result := make([]string, 0, len(expressions)) + for _, expr := range expressions { + if expr != "" && !seen[expr] { + seen[expr] = true + result = append(result, expr) } - licenseExpression += cp.License } - return licenseExpression + + return result +} + +// containsOperator checks if a license expression contains AND/OR operators +func containsOperator(expr string) bool { + return strings.Contains(expr, " AND ") || strings.Contains(expr, " OR ") } // LicensingInfos looks at the `Package.Copyright[].LicensePath` fields of the @@ -442,40 +567,69 @@ func (p Package) LicenseExpression() string { // LicensingInfos opens the file at this path from the build's workspace // directory, and reads in the license content. LicensingInfos then returns a // map of the `Copyright.License` field to the string content of the file from -// `.LicensePath`. +// `.LicensePath`. This function supports both simple and grouped copyright structures. func (p Package) LicensingInfos(WorkspaceDir string) (map[string]string, error) { licenseInfos := make(map[string]string) for _, cp := range p.Copyright { - if cp.LicensePath != "" { - content, err := os.ReadFile(filepath.Join(WorkspaceDir, cp.LicensePath)) - if err != nil { - return nil, fmt.Errorf("failed to read licensepath %q: %w", cp.LicensePath, err) - } - licenseInfos[cp.License] = string(content) + if err := collectLicensingInfos(cp, WorkspaceDir, licenseInfos); err != nil { + return nil, err } } return licenseInfos, nil } +// collectLicensingInfos recursively collects license path information from copyright entries +func collectLicensingInfos(cp Copyright, workspaceDir string, licenseInfos map[string]string) error { + // Handle simple license entry + if cp.License != "" && cp.LicensePath != "" { + content, err := os.ReadFile(filepath.Join(workspaceDir, cp.LicensePath)) + if err != nil { + return fmt.Errorf("failed to read licensepath %q: %w", cp.LicensePath, err) + } + licenseInfos[cp.License] = string(content) + } + + // Handle grouped license entries + for _, subCp := range cp.Licenses { + if err := collectLicensingInfos(subCp, workspaceDir, licenseInfos); err != nil { + return err + } + } + + return nil +} + // FullCopyright returns the concatenated copyright expressions defined // in the configuration file. func (p Package) FullCopyright() string { - copyright := "" - for _, cp := range p.Copyright { - if cp.Attestation != "" { - copyright += cp.Attestation + "\n" - } - } - // No copyright found, instead of ommitting the field declare + copyrights := collectCopyright(p.Copyright) + // No copyrights found, instead of ommitting the field declare // that no determination was attempted, which is better than a // whitespace (which should also be interpreted as // NOASSERTION) - if copyright == "" { - copyright = "NOASSERTION" + copyright := "NOASSERTION" + if len(copyrights) > 0 { + // Otherwise, join the copyrights with newlines + copyright = strings.Join(copyrights, "\n") } return copyright } +// collectCopyright recursively collects copyright entries from copyright structs. +func collectCopyright(cps []Copyright) []string { + copyrights := []string{} + for _, cp := range cps { + // Check if this is a grouping entry + if cp.Operator != "" && len(cp.Licenses) > 0 { + // This is a grouping entry, collect its sub-licenses + copyrights = append(copyrights, collectCopyright(cp.Licenses)...) + } else if cp.Attestation != "" { + copyrights = append(copyrights, cp.Attestation) + } + } + return copyrights +} + type Needs struct { // A list of packages needed by this pipeline Packages []string @@ -1703,6 +1857,9 @@ func (cfg Configuration) validate(ctx context.Context) error { if err := validateCapabilities(cfg.Package.SetCap); err != nil { return ErrInvalidConfiguration{Problem: err} } + if err := validateCopyright(cfg.Package.Copyright); err != nil { + return ErrInvalidConfiguration{Problem: err} + } saw := map[string]int{cfg.Package.Name: -1} for i, sp := range cfg.Subpackages { @@ -1914,6 +2071,64 @@ func validateCapabilities(setcap []Capability) error { return errors.Join(errs...) } +// validateCopyright validates the copyright structure including nested license groups +func validateCopyright(copyrights []Copyright) error { + var errs []error + + for i, cp := range copyrights { + if err := validateSingleCopyright(cp, fmt.Sprintf("copyright[%d]", i)); err != nil { + errs = append(errs, err) + } + } + + if len(errs) == 0 { + return nil + } + + return errors.Join(errs...) +} + +// validateSingleCopyright validates a single copyright entry (simple or grouped) +func validateSingleCopyright(cp Copyright, path string) error { + var errs []error + + // Check if this is a grouping entry + isGrouping := cp.Operator != "" || len(cp.Licenses) > 0 + isSimple := cp.License != "" + + if isGrouping && isSimple { + errs = append(errs, fmt.Errorf("%s: cannot specify both 'license' field and grouping ('operator'/'licenses') fields", path)) + } + + if !isGrouping && !isSimple { + errs = append(errs, fmt.Errorf("%s: must specify either 'license' field or grouping ('operator'/'licenses') fields", path)) + } + + if isGrouping { + // Validate grouping entry + if cp.Operator != "" && cp.Operator != "AND" && cp.Operator != "OR" { + errs = append(errs, fmt.Errorf("%s: operator must be 'AND' or 'OR', got %q", path, cp.Operator)) + } + + if len(cp.Licenses) == 0 { + errs = append(errs, fmt.Errorf("%s: grouping entry must have at least one license in 'licenses' field", path)) + } + + // Recursively validate sub-licenses + for i, subCp := range cp.Licenses { + if err := validateSingleCopyright(subCp, fmt.Sprintf("%s.licenses[%d]", path, i)); err != nil { + errs = append(errs, err) + } + } + } + + if len(errs) == 0 { + return nil + } + + return errors.Join(errs...) +} + type capabilityData struct { Effective uint32 Permitted uint32 diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 33024b190..745544d22 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1270,3 +1270,860 @@ func TestSetCapability(t *testing.T) { }) } } + +func TestLicenseExpression(t *testing.T) { + tests := []struct { + name string + copyright []Copyright + expected string + }{ + { + name: "nil copyright", + copyright: nil, + expected: "", + }, + { + name: "empty copyright", + copyright: []Copyright{}, + expected: "", + }, + { + name: "single simple license", + copyright: []Copyright{ + {License: "MIT"}, + }, + expected: "MIT", + }, + { + name: "multiple simple licenses (default AND)", + copyright: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + expected: "MIT AND Apache-2.0", + }, + { + name: "OR group with two licenses", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + }, + expected: "MIT OR Apache-2.0", + }, + { + name: "AND group with two licenses", + copyright: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "GPL-3.0-or-later"}, + }, + }, + }, + expected: "MIT AND GPL-3.0-or-later", + }, + { + name: "mixed simple and grouped licenses", + copyright: []Copyright{ + {License: "BSD-3-Clause"}, + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + }, + expected: "BSD-3-Clause AND (MIT OR Apache-2.0)", + }, + { + name: "nested groups", + copyright: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "BSD-3-Clause"}, + }, + }, + {License: "LGPL-2.1-or-later"}, + }, + }, + }, + expected: "(MIT OR BSD-3-Clause) AND LGPL-2.1-or-later", + }, + { + name: "complex nested structure", + copyright: []Copyright{ + {License: "MIT"}, + { + Operator: "OR", + Licenses: []Copyright{ + {License: "Apache-2.0"}, + {License: "GPL-3.0-or-later"}, + }, + }, + { + Operator: "AND", + Licenses: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "BSD-3-Clause"}, + }, + }, + {License: "LGPL-2.1-or-later"}, + }, + }, + }, + expected: "MIT AND (Apache-2.0 OR GPL-3.0-or-later) AND ((MIT OR BSD-3-Clause) AND LGPL-2.1-or-later)", + }, + { + name: "single license in group", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + }, + }, + }, + expected: "MIT", + }, + { + name: "all entries with same operator", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + { + Operator: "OR", + Licenses: []Copyright{ + {License: "BSD-3-Clause"}, + {License: "GPL-3.0-or-later"}, + }, + }, + }, + expected: "(MIT OR Apache-2.0) OR (BSD-3-Clause OR GPL-3.0-or-later)", + }, + { + name: "duplicate licenses in group", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + {License: "MIT"}, // Duplicate + }, + }, + }, + expected: "MIT OR Apache-2.0", + }, + { + name: "duplicate licenses across groups", + copyright: []Copyright{ + {License: "MIT"}, + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, // Same as above + {License: "Apache-2.0"}, + }, + }, + }, + expected: "MIT AND (MIT OR Apache-2.0)", // Duplicates across different levels are preserved + }, + { + name: "complex duplicates within nested structure", + copyright: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "BSD-3-Clause"}, + {License: "MIT"}, // Duplicate within OR group + }, + }, + {License: "LGPL-2.1-or-later"}, + {License: "LGPL-2.1-or-later"}, // Duplicate within AND group + }, + }, + }, + expected: "(MIT OR BSD-3-Clause) AND LGPL-2.1-or-later", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := Package{Copyright: tt.copyright} + result := pkg.LicenseExpression() + require.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateCopyright(t *testing.T) { + tests := []struct { + name string + copyright []Copyright + wantErr bool + errMsg string + }{ + { + name: "nil copyright", + copyright: nil, + wantErr: false, + }, + { + name: "empty copyright", + copyright: []Copyright{}, + wantErr: false, + }, + { + name: "valid simple license", + copyright: []Copyright{ + {License: "MIT"}, + }, + wantErr: false, + }, + { + name: "valid OR group", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid AND group", + copyright: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "GPL-3.0-or-later"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid: both license and operator", + copyright: []Copyright{ + { + License: "MIT", + Operator: "OR", + Licenses: []Copyright{ + {License: "Apache-2.0"}, + }, + }, + }, + wantErr: true, + errMsg: "cannot specify both 'license' field and grouping", + }, + { + name: "invalid: neither license nor grouping", + copyright: []Copyright{ + {}, + }, + wantErr: true, + errMsg: "must specify either 'license' field or grouping", + }, + { + name: "invalid operator", + copyright: []Copyright{ + { + Operator: "XOR", + Licenses: []Copyright{ + {License: "MIT"}, + }, + }, + }, + wantErr: true, + errMsg: "operator must be 'AND' or 'OR'", + }, + { + name: "empty licenses in group", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{}, + }, + }, + wantErr: true, + errMsg: "grouping entry must have at least one license", + }, + { + name: "nested invalid structure", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {}, // Invalid nested entry + }, + }, + }, + wantErr: true, + errMsg: "must specify either 'license' field or grouping", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCopyright(tt.copyright) + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + require.Contains(t, err.Error(), tt.errMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCollectLicensingInfos(t *testing.T) { + // Create a temporary directory with test license files + tmpDir := t.TempDir() + + // Create test license files + mitLicense := "MIT License\n\nCopyright (c) 2023 Test" + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "LICENSE-MIT"), []byte(mitLicense), 0644)) + + apacheLicense := "Apache License 2.0\n\nCopyright 2023 Test" + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "LICENSE-Apache"), []byte(apacheLicense), 0644)) + + tests := []struct { + name string + copyright []Copyright + expected map[string]string + wantErr bool + }{ + { + name: "simple license with path", + copyright: []Copyright{ + { + License: "MIT", + LicensePath: "LICENSE-MIT", + }, + }, + expected: map[string]string{ + "MIT": mitLicense, + }, + wantErr: false, + }, + { + name: "grouped licenses with paths", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + { + License: "MIT", + LicensePath: "LICENSE-MIT", + }, + { + License: "Apache-2.0", + LicensePath: "LICENSE-Apache", + }, + }, + }, + }, + expected: map[string]string{ + "MIT": mitLicense, + "Apache-2.0": apacheLicense, + }, + wantErr: false, + }, + { + name: "mixed structure with paths", + copyright: []Copyright{ + { + License: "MIT", + LicensePath: "LICENSE-MIT", + }, + { + Operator: "AND", + Licenses: []Copyright{ + {License: "GPL-3.0-or-later"}, // No path + { + License: "Apache-2.0", + LicensePath: "LICENSE-Apache", + }, + }, + }, + }, + expected: map[string]string{ + "MIT": mitLicense, + "Apache-2.0": apacheLicense, + }, + wantErr: false, + }, + { + name: "license without path", + copyright: []Copyright{ + {License: "MIT"}, + }, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "nonexistent license file", + copyright: []Copyright{ + { + License: "MIT", + LicensePath: "nonexistent-file", + }, + }, + expected: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := Package{Copyright: tt.copyright} + result, err := pkg.LicensingInfos(tmpDir) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, result) + } + }) + } +} + +func TestBuildSingleLicenseExpression(t *testing.T) { + tests := []struct { + name string + copyright Copyright + expected string + }{ + { + name: "empty copyright", + copyright: Copyright{}, + expected: "", + }, + { + name: "simple license", + copyright: Copyright{License: "MIT"}, + expected: "MIT", + }, + { + name: "OR group", + copyright: Copyright{ + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + expected: "(MIT OR Apache-2.0)", + }, + { + name: "single license in group (no parentheses)", + copyright: Copyright{ + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + }, + }, + expected: "MIT", + }, + { + name: "nested structure", + copyright: Copyright{ + Operator: "AND", + Licenses: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "BSD-3-Clause"}, + }, + }, + {License: "GPL-3.0-or-later"}, + }, + }, + expected: "((MIT OR BSD-3-Clause) AND GPL-3.0-or-later)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildSingleLicenseExpression(tt.copyright, true) // Test with parentheses needed + require.Equal(t, tt.expected, result) + }) + } +} + +func TestCombineExpressions(t *testing.T) { + tests := []struct { + name string + expressions []string + operator string + expected string + }{ + { + name: "empty expressions", + expressions: []string{}, + operator: "AND", + expected: "", + }, + { + name: "single expression", + expressions: []string{"MIT"}, + operator: "AND", + expected: "MIT", + }, + { + name: "two expressions with AND", + expressions: []string{"MIT", "Apache-2.0"}, + operator: "AND", + expected: "MIT AND Apache-2.0", + }, + { + name: "two expressions with OR", + expressions: []string{"MIT", "Apache-2.0"}, + operator: "OR", + expected: "MIT OR Apache-2.0", + }, + { + name: "invalid operator defaults to AND", + expressions: []string{"MIT", "Apache-2.0"}, + operator: "XOR", + expected: "MIT AND Apache-2.0", + }, + { + name: "three expressions", + expressions: []string{"MIT", "Apache-2.0", "GPL-3.0-or-later"}, + operator: "OR", + expected: "MIT OR Apache-2.0 OR GPL-3.0-or-later", + }, + { + name: "expressions with duplicates", + expressions: []string{"MIT", "Apache-2.0", "MIT", "GPL-3.0-or-later"}, + operator: "OR", + expected: "MIT OR Apache-2.0 OR GPL-3.0-or-later", + }, + { + name: "all duplicates", + expressions: []string{"MIT", "MIT", "MIT"}, + operator: "AND", + expected: "MIT", + }, + { + name: "expressions with empty strings", + expressions: []string{"MIT", "", "Apache-2.0", ""}, + operator: "OR", + expected: "MIT OR Apache-2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := combineExpressions(tt.expressions, tt.operator) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestContainsOperator(t *testing.T) { + tests := []struct { + name string + expr string + expected bool + }{ + { + name: "simple license", + expr: "MIT", + expected: false, + }, + { + name: "contains AND", + expr: "MIT AND Apache-2.0", + expected: true, + }, + { + name: "contains OR", + expr: "MIT OR Apache-2.0", + expected: true, + }, + { + name: "contains both", + expr: "(MIT OR Apache-2.0) AND GPL-3.0-or-later", + expected: true, + }, + { + name: "false positive (partial match)", + expr: "ANDROID-SDK", // Contains "AND" but not " AND " + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := containsOperator(tt.expr) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestDeduplicateExpressions(t *testing.T) { + tests := []struct { + name string + expressions []string + expected []string + }{ + { + name: "no duplicates", + expressions: []string{"MIT", "Apache-2.0", "GPL-3.0-or-later"}, + expected: []string{"MIT", "Apache-2.0", "GPL-3.0-or-later"}, + }, + { + name: "with duplicates", + expressions: []string{"MIT", "Apache-2.0", "MIT", "GPL-3.0-or-later"}, + expected: []string{"MIT", "Apache-2.0", "GPL-3.0-or-later"}, + }, + { + name: "all duplicates", + expressions: []string{"MIT", "MIT", "MIT"}, + expected: []string{"MIT"}, + }, + { + name: "empty expressions", + expressions: []string{}, + expected: []string{}, + }, + { + name: "single expression", + expressions: []string{"MIT"}, + expected: []string{"MIT"}, + }, + { + name: "with empty strings", + expressions: []string{"MIT", "", "Apache-2.0", "", "MIT"}, + expected: []string{"MIT", "Apache-2.0"}, + }, + { + name: "preserves order", + expressions: []string{"GPL-3.0-or-later", "MIT", "Apache-2.0", "MIT"}, + expected: []string{"GPL-3.0-or-later", "MIT", "Apache-2.0"}, + }, + { + name: "complex expressions with duplicates", + expressions: []string{"(MIT OR Apache-2.0)", "GPL-3.0-or-later", "(MIT OR Apache-2.0)"}, + expected: []string{"(MIT OR Apache-2.0)", "GPL-3.0-or-later"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deduplicateExpressions(tt.expressions) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestFullCopyright(t *testing.T) { + tests := []struct { + name string + copyright []Copyright + expected string + }{ + { + name: "nil copyright", + copyright: nil, + expected: "NOASSERTION", + }, + { + name: "empty copyright", + copyright: []Copyright{}, + expected: "NOASSERTION", + }, + { + name: "single copyright with attestation", + copyright: []Copyright{ + {Attestation: "Copyright 2023 Example Corp"}, + }, + expected: "Copyright 2023 Example Corp", + }, + { + name: "multiple copyright attestations", + copyright: []Copyright{ + {Attestation: "Copyright 2023 Example Corp"}, + {Attestation: "Copyright 2024 Another Corp"}, + }, + expected: "Copyright 2023 Example Corp\nCopyright 2024 Another Corp", + }, + { + name: "simple license without attestation", + copyright: []Copyright{ + {License: "MIT"}, + }, + expected: "NOASSERTION", + }, + { + name: "grouped copyrights with attestations", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 First Corp"}, + {Attestation: "Copyright 2023 Second Corp"}, + }, + }, + }, + expected: "Copyright 2023 First Corp\nCopyright 2023 Second Corp", + }, + { + name: "mixed simple and grouped copyrights", + copyright: []Copyright{ + {Attestation: "Copyright 2023 Main Corp"}, + { + Operator: "AND", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 Sub Corp A"}, + {Attestation: "Copyright 2023 Sub Corp B"}, + }, + }, + }, + expected: "Copyright 2023 Main Corp\nCopyright 2023 Sub Corp A\nCopyright 2023 Sub Corp B", + }, + { + name: "nested groups with attestations", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 Nested Corp A"}, + {Attestation: "Copyright 2023 Nested Corp B"}, + }, + }, + {Attestation: "Copyright 2023 Alternative Corp"}, + }, + }, + }, + expected: "Copyright 2023 Nested Corp A\nCopyright 2023 Nested Corp B\nCopyright 2023 Alternative Corp", + }, + { + name: "deeply nested groups", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 Deep Corp A"}, + {Attestation: "Copyright 2023 Deep Corp B"}, + }, + }, + {Attestation: "Copyright 2023 Mid Corp"}, + }, + }, + {Attestation: "Copyright 2023 Top Corp"}, + }, + }, + }, + expected: "Copyright 2023 Deep Corp A\nCopyright 2023 Deep Corp B\nCopyright 2023 Mid Corp\nCopyright 2023 Top Corp", + }, + { + name: "mixed licenses and attestations", + copyright: []Copyright{ + {License: "MIT"}, + {Attestation: "Copyright 2023 Example Corp"}, + { + Operator: "OR", + Licenses: []Copyright{ + {License: "Apache-2.0"}, + {Attestation: "Copyright 2023 Alternative Corp"}, + }, + }, + }, + expected: "Copyright 2023 Example Corp\nCopyright 2023 Alternative Corp", + }, + { + name: "groups with no attestations (only licenses)", + copyright: []Copyright{ + { + Operator: "OR", + Licenses: []Copyright{ + {License: "MIT"}, + {License: "Apache-2.0"}, + }, + }, + }, + expected: "NOASSERTION", + }, + { + name: "complex real-world scenario", + copyright: []Copyright{ + {Attestation: "Copyright 2023 Main Project Contributors"}, + { + Operator: "OR", + Licenses: []Copyright{ + { + Operator: "AND", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 Vendor A"}, + {License: "MIT"}, + }, + }, + { + Operator: "AND", + Licenses: []Copyright{ + {Attestation: "Copyright 2023 Vendor B"}, + {License: "Apache-2.0"}, + }, + }, + }, + }, + {Attestation: "Copyright 2023 Additional Contributors"}, + }, + expected: "Copyright 2023 Main Project Contributors\nCopyright 2023 Vendor A\nCopyright 2023 Vendor B\nCopyright 2023 Additional Contributors", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pkg := Package{Copyright: tt.copyright} + result := pkg.FullCopyright() + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/license/license.go b/pkg/license/license.go index 1bb9c4ee2..e57f2cbc1 100644 --- a/pkg/license/license.go +++ b/pkg/license/license.go @@ -336,30 +336,47 @@ func LicenseCheck(ctx context.Context, cfg *config.Configuration, fsys fs.FS) ([ } // gatherMelangeLicenses gathers the licenses from the melange configuration and splits them into separate entries. +// It now supports both simple license entries and hierarchical license groups with AND/OR operators. func gatherMelangeLicenses(cfg *config.Configuration) []License { mls := []License{} for _, ml := range cfg.Package.Copyright { - if strings.Contains(ml.License, " OR ") || strings.Contains(ml.License, " AND ") { - // Split the license into separate entries using regexp - sls := regexp.MustCompile(`\s+(AND|OR)\s+`).Split(ml.License, -1) + mls = append(mls, gatherLicensesFromCopyright(ml)...) + } + return mls +} + +// gatherLicensesFromCopyright recursively extracts license information from a Copyright entry. +// It handles both simple license entries and grouped license structures. +func gatherLicensesFromCopyright(cp config.Copyright) []License { + var licenses []License + + // Check if this is a grouping entry (has Operator and Licenses) + if cp.Operator != "" && len(cp.Licenses) > 0 { + for _, subCp := range cp.Licenses { + licenses = append(licenses, gatherLicensesFromCopyright(subCp)...) + } + } else if cp.License != "" { + // This is a simple license entry + if strings.Contains(cp.License, " OR ") || strings.Contains(cp.License, " AND ") { + // Split the license into separate entries using regexp for backward compatibility + sls := regexp.MustCompile(`\s+(AND|OR)\s+`).Split(cp.License, -1) for _, sl := range sls { - mls = append(mls, - License{ - Name: sl, - Source: ml.LicensePath, - Overrides: ml.DetectionOverride, - }) + licenses = append(licenses, License{ + Name: sl, + Source: cp.LicensePath, + Overrides: cp.DetectionOverride, + }) } } else { - mls = append(mls, - License{ - Name: ml.License, - Source: ml.LicensePath, - Overrides: ml.DetectionOverride, - }) + licenses = append(licenses, License{ + Name: cp.License, + Source: cp.LicensePath, + Overrides: cp.DetectionOverride, + }) } } - return mls + + return licenses } // getLicenseDifferences compares the detected licenses with the melange licenses and returns the differences. diff --git a/pkg/license/license_test.go b/pkg/license/license_test.go index 7a6bad6a3..5ccd8f84b 100644 --- a/pkg/license/license_test.go +++ b/pkg/license/license_test.go @@ -330,3 +330,233 @@ func TestLicenseCheck_withOverrides(t *testing.T) { } } } + +func TestGatherMelangeLicenses_GroupedStructure(t *testing.T) { + tests := []struct { + name string + cfg *config.Configuration + expected []License + }{ + { + name: "simple license entries", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + {License: "Apache-2.0", LicensePath: "LICENSE"}, + {License: "MIT", LicensePath: "LICENSE-MIT"}, + }, + }, + }, + expected: []License{ + {Name: "Apache-2.0", Source: "LICENSE"}, + {Name: "MIT", Source: "LICENSE-MIT"}, + }, + }, + { + name: "simple AND grouping", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + { + Operator: "AND", + Licenses: []config.Copyright{ + {License: "Apache-2.0", LicensePath: "LICENSE-APACHE"}, + {License: "MIT", LicensePath: "LICENSE-MIT"}, + }, + }, + }, + }, + }, + expected: []License{ + {Name: "Apache-2.0", Source: "LICENSE-APACHE"}, + {Name: "MIT", Source: "LICENSE-MIT"}, + }, + }, + { + name: "simple OR grouping", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + { + Operator: "OR", + Licenses: []config.Copyright{ + {License: "GPL-2.0", LicensePath: "LICENSE-GPL2"}, + {License: "GPL-3.0", LicensePath: "LICENSE-GPL3"}, + }, + }, + }, + }, + }, + expected: []License{ + {Name: "GPL-2.0", Source: "LICENSE-GPL2"}, + {Name: "GPL-3.0", Source: "LICENSE-GPL3"}, + }, + }, + { + name: "nested grouping", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + {License: "Apache-2.0", LicensePath: "LICENSE-APACHE"}, + { + Operator: "OR", + Licenses: []config.Copyright{ + { + Operator: "AND", + Licenses: []config.Copyright{ + {License: "GPL-2.0", LicensePath: "LICENSE-GPL2"}, + {License: "LGPL-2.1", LicensePath: "LICENSE-LGPL"}, + }, + }, + {License: "MIT", LicensePath: "LICENSE-MIT"}, + }, + }, + }, + }, + }, + expected: []License{ + {Name: "Apache-2.0", Source: "LICENSE-APACHE"}, + {Name: "GPL-2.0", Source: "LICENSE-GPL2"}, + {Name: "LGPL-2.1", Source: "LICENSE-LGPL"}, + {Name: "MIT", Source: "LICENSE-MIT"}, + }, + }, + { + name: "mixed simple and grouped licenses", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + {License: "Apache-2.0", LicensePath: "LICENSE-APACHE"}, + {License: "MIT OR BSD-3-Clause"}, + { + Operator: "AND", + Licenses: []config.Copyright{ + {License: "GPL-2.0", LicensePath: "LICENSE-GPL2"}, + {License: "LGPL-2.1", LicensePath: "LICENSE-LGPL"}, + }, + }, + }, + }, + }, + expected: []License{ + {Name: "Apache-2.0", Source: "LICENSE-APACHE"}, + {Name: "MIT"}, + {Name: "BSD-3-Clause"}, + {Name: "GPL-2.0", Source: "LICENSE-GPL2"}, + {Name: "LGPL-2.1", Source: "LICENSE-LGPL"}, + }, + }, + { + name: "grouping with detection overrides", + cfg: &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + { + Operator: "OR", + Licenses: []config.Copyright{ + {License: "MIT", LicensePath: "LICENSE-MIT", DetectionOverride: "Expat"}, + {License: "Apache-2.0", LicensePath: "LICENSE-APACHE"}, + }, + }, + }, + }, + }, + expected: []License{ + {Name: "MIT", Source: "LICENSE-MIT", Overrides: "Expat"}, + {Name: "Apache-2.0", Source: "LICENSE-APACHE"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := gatherMelangeLicenses(tt.cfg) + + if len(result) != len(tt.expected) { + t.Errorf("Expected %d licenses, got %d", len(tt.expected), len(result)) + return + } + + for i, expected := range tt.expected { + if i >= len(result) { + t.Errorf("Missing license at index %d: expected %+v", i, expected) + continue + } + + actual := result[i] + if actual.Name != expected.Name { + t.Errorf("License %d: expected name %q, got %q", i, expected.Name, actual.Name) + } + if actual.Source != expected.Source { + t.Errorf("License %d: expected source %q, got %q", i, expected.Source, actual.Source) + } + if actual.Overrides != expected.Overrides { + t.Errorf("License %d: expected overrides %q, got %q", i, expected.Overrides, actual.Overrides) + } + } + }) + } +} + +func TestLicenseCheck_withGroupedLicenses(t *testing.T) { + // Create a mock configuration with grouped license structure + cfg := &config.Configuration{ + Package: config.Package{ + Copyright: []config.Copyright{ + {License: "Apache-2.0", LicensePath: "LICENSE-APACHE"}, + { + Operator: "OR", + Licenses: []config.Copyright{ + {License: "GPL-2.0", LicensePath: "LICENSE-GPLv2"}, + {License: "GPL-3.0", LicensePath: "LICENSE-GPLv3"}, + }, + }, + { + Operator: "AND", + Licenses: []config.Copyright{ + {License: "MIT", LicensePath: "LICENSE-BSD"}, // This should cause a difference since BSD-3-Clause is detected + {License: "ISC", LicensePath: "LICENSE-NONEXISTENT"}, // This should cause a difference since the file doesn't exist + }, + }, + }, + }, + } + + testDataDir := "testdata" + dataFS := apkofs.DirFS(testDataDir) + + // Call function under test + _, diffs, err := LicenseCheck(context.Background(), cfg, dataFS) + if err != nil { + t.Fatalf("LicenseCheck returned an error: %v", err) + } + + // Expected differences: + // 1. LICENSE-BSD should have a difference (MIT expected vs BSD-3-Clause detected) + expectedDiffs := []LicenseDiff{ + { + Path: "LICENSE-BSD", + Is: "MIT", + Should: "BSD-3-Clause", + }, + } + + // Verify we have the expected number of differences + if len(diffs) < len(expectedDiffs) { + t.Errorf("Expected at least %d license differences, got %d", len(expectedDiffs), len(diffs)) + } + + // Check for specific expected differences + for _, expected := range expectedDiffs { + found := false + for _, diff := range diffs { + if diff.Path == expected.Path && diff.Is == expected.Is && diff.Should == expected.Should { + found = true + break + } + } + if !found { + t.Errorf("Expected license difference %+v not found in %+v", expected, diffs) + } + } +} diff --git a/pkg/renovate/copyright/copyright.go b/pkg/renovate/copyright/copyright.go index 6ef7d030a..97b256af6 100644 --- a/pkg/renovate/copyright/copyright.go +++ b/pkg/renovate/copyright/copyright.go @@ -17,6 +17,7 @@ package copyright import ( "context" "fmt" + "path/filepath" "github.com/chainguard-dev/clog" @@ -28,8 +29,9 @@ import ( // CopyrightConfig contains the configuration data for a copyright update // renovator. type CopyrightConfig struct { - Licenses []license.License - Diffs []license.LicenseDiff + Licenses []license.License + Diffs []license.LicenseDiff + Structured bool // Enable structured license grouping by directory } // Option sets a config option on a CopyrightConfig. @@ -53,6 +55,16 @@ func WithDiffs(diffs []license.LicenseDiff) Option { } } +// WithStructured enables structured license grouping by directory. +// When enabled, licenses are grouped by directory level with OR operators +// within directories and AND operators between directories. +func WithStructured(structured bool) Option { + return func(cfg *CopyrightConfig) error { + cfg.Structured = structured + return nil + } +} + // New returns a renovator which performs a copyright update. func New(ctx context.Context, opts ...Option) renovate.Renovator { log := clog.FromContext(ctx) @@ -103,47 +115,174 @@ func New(ctx context.Context, opts ...Option) renovate.Renovator { // detected licenses. copyrightNode.Content = nil - // Repopulate the copyrightNode with detected licenses - for _, l := range ccfg.Licenses { - // Skip licenses we don't have full confidence in. - if !license.IsLicenseMatchConfident(l) { - log.Infof("skipping unconfident license %s", l.Source) - continue + if ccfg.Structured { + // Use structured grouping by directory + if err := populateStructuredCopyright(copyrightNode, ccfg.Licenses, log); err != nil { + return err } - - licenseNode := &yaml.Node{ - Kind: yaml.MappingNode, - Style: yaml.FlowStyle, - Content: []*yaml.Node{}, + } else { + // Use flat license listing (original behavior) + if err := populateFlatCopyright(copyrightNode, ccfg.Licenses, log); err != nil { + return err } + } - licenseNode.Content = append(licenseNode.Content, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: "license", - Tag: "!!str", - Style: yaml.FlowStyle, - }, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: l.Name, - Tag: "!!str", - Style: yaml.FlowStyle, - }) - - licenseNode.Content = append(licenseNode.Content, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: "license-path", - Tag: "!!str", - Style: yaml.FlowStyle, - }, &yaml.Node{ - Kind: yaml.ScalarNode, - Value: l.Source, - Tag: "!!str", - Style: yaml.FlowStyle, - }) + return nil + } +} - copyrightNode.Content = append(copyrightNode.Content, licenseNode) +// populateFlatCopyright populates the copyright node with a flat list of licenses (original behavior). +func populateFlatCopyright(copyrightNode *yaml.Node, licenses []license.License, log *clog.Logger) error { + for _, l := range licenses { + // Skip licenses we don't have full confidence in. + if !license.IsLicenseMatchConfident(l) { + log.Infof("skipping unconfident license %s", l.Source) + continue } + licenseNode := createSimpleLicenseNode(l) + copyrightNode.Content = append(copyrightNode.Content, licenseNode) + } + return nil +} + +// populateStructuredCopyright populates the copyright node with structured license grouping by directory. +func populateStructuredCopyright(copyrightNode *yaml.Node, licenses []license.License, log *clog.Logger) error { + // Filter out unconfident licenses + confidentLicenses := make([]license.License, 0, len(licenses)) + for _, l := range licenses { + if !license.IsLicenseMatchConfident(l) { + log.Infof("skipping unconfident license %s", l.Source) + continue + } + confidentLicenses = append(confidentLicenses, l) + } + + if len(confidentLicenses) == 0 { return nil } + + // Group licenses by directory + licenseGroups := groupLicensesByDirectory(confidentLicenses) + + // If we only have one directory group, check if we need nested structure + if len(licenseGroups) == 1 { + // If there's only one directory with one license, use simple format + for _, licenses := range licenseGroups { + if len(licenses) == 1 { + licenseNode := createSimpleLicenseNode(licenses[0]) + copyrightNode.Content = append(copyrightNode.Content, licenseNode) + return nil + } + } + } + + // Create structured copyright entries + for _, dirLicenses := range licenseGroups { + if len(dirLicenses) == 1 { + // Single license in directory - use simple entry + licenseNode := createSimpleLicenseNode(dirLicenses[0]) + copyrightNode.Content = append(copyrightNode.Content, licenseNode) + } else { + // Multiple licenses in same directory - group with OR + groupNode := createLicenseGroupNode("OR", dirLicenses) + copyrightNode.Content = append(copyrightNode.Content, groupNode) + } + } + + // If we have multiple directory groups, we need to wrap everything in an AND group + if len(licenseGroups) > 1 { + // Move all current content to a new AND group + originalContent := copyrightNode.Content + copyrightNode.Content = nil + + andGroupNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{}, + } + + // Add operator field + andGroupNode.Content = append(andGroupNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "operator", Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: "AND", Tag: "!!str"}, + ) + + // Add licenses field + andGroupNode.Content = append(andGroupNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "licenses", Tag: "!!str"}, + &yaml.Node{Kind: yaml.SequenceNode, Content: originalContent}, + ) + + copyrightNode.Content = append(copyrightNode.Content, andGroupNode) + } + + return nil +} + +// groupLicensesByDirectory groups licenses by their directory path. +func groupLicensesByDirectory(licenses []license.License) map[string][]license.License { + groups := make(map[string][]license.License) + + for _, l := range licenses { + dir := filepath.Dir(l.Source) + if dir == "" || dir == "." { + dir = "." // Root directory + } + groups[dir] = append(groups[dir], l) + } + + return groups +} + +// createSimpleLicenseNode creates a simple license node with license and license-path fields. +func createSimpleLicenseNode(l license.License) *yaml.Node { + licenseNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{}, + } + + licenseNode.Content = append(licenseNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "license", Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: l.Name, Tag: "!!str"}, + ) + + licenseNode.Content = append(licenseNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "license-path", Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: l.Source, Tag: "!!str"}, + ) + + return licenseNode +} + +// createLicenseGroupNode creates a license group node with an operator and list of licenses. +func createLicenseGroupNode(operator string, licenses []license.License) *yaml.Node { + groupNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{}, + } + + // Add operator field + groupNode.Content = append(groupNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "operator", Tag: "!!str"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: operator, Tag: "!!str"}, + ) + + // Create licenses sequence + licensesSeq := &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{}, + } + + for _, l := range licenses { + licenseNode := createSimpleLicenseNode(l) + licensesSeq.Content = append(licensesSeq.Content, licenseNode) + } + + // Add licenses field + groupNode.Content = append(groupNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "licenses", Tag: "!!str"}, + licensesSeq, + ) + + return groupNode } diff --git a/pkg/renovate/copyright/copyright_test.go b/pkg/renovate/copyright/copyright_test.go index 9651639ed..cc3eff8ce 100644 --- a/pkg/renovate/copyright/copyright_test.go +++ b/pkg/renovate/copyright/copyright_test.go @@ -126,3 +126,316 @@ func TestCopyright_noDiffs(t *testing.T) { assert.Contains(t, string(resultData), "license: Not-Applicable") assert.NotContains(t, string(resultData), "license: Invalid") } + +func TestCopyright_structuredMode_singleDirectory(t *testing.T) { + dir := t.TempDir() + ctx := slogtest.Context(t) + + // Two licenses in the same directory (root) + detectedLicenses := []license.License{ + { + Name: "Apache-2.0", + Source: "LICENSE", + Confidence: 1.0, + }, + { + Name: "MIT", + Source: "COPYING", + Confidence: 1.0, + }, + } + + diffs := []license.LicenseDiff{ + { + Path: "LICENSE", + Is: "GPL-2.0", + Should: "Apache-2.0", + }, + } + + // Copy the test data file to the temp directory + src := filepath.Join("testdata", "nolicense.yaml") + testFile := filepath.Join(dir, "nolicense.yaml") + input, err := os.ReadFile(src) + assert.NoError(t, err) + + err = os.WriteFile(testFile, input, 0644) + assert.NoError(t, err) + + rctx, err := renovate.New(renovate.WithConfig(testFile)) + assert.NoError(t, err) + + copyrightRenovator := New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(true)) + + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + resultData, err := os.ReadFile(testFile) + assert.NoError(t, err) + + // With structured mode and multiple licenses in same directory, should create OR group + assert.Contains(t, string(resultData), "operator: OR") + assert.Contains(t, string(resultData), "license: Apache-2.0") + assert.Contains(t, string(resultData), "license: MIT") + assert.NotContains(t, string(resultData), "license: Not-Applicable") +} + +func TestCopyright_structuredMode_multipleDirectories(t *testing.T) { + dir := t.TempDir() + ctx := slogtest.Context(t) + + // Licenses in different directories + detectedLicenses := []license.License{ + { + Name: "Apache-2.0", + Source: "LICENSE", + Confidence: 1.0, + }, + { + Name: "MIT", + Source: "internal/COPYING", + Confidence: 1.0, + }, + { + Name: "GPL-3.0", + Source: "vendor/third-party/LICENSE", + Confidence: 1.0, + }, + } + + diffs := []license.LicenseDiff{ + { + Path: "LICENSE", + Is: "GPL-2.0", + Should: "Apache-2.0", + }, + } + + // Copy the test data file to the temp directory + src := filepath.Join("testdata", "nolicense.yaml") + testFile := filepath.Join(dir, "nolicense.yaml") + input, err := os.ReadFile(src) + assert.NoError(t, err) + + err = os.WriteFile(testFile, input, 0644) + assert.NoError(t, err) + + rctx, err := renovate.New(renovate.WithConfig(testFile)) + assert.NoError(t, err) + + copyrightRenovator := New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(true)) + + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + resultData, err := os.ReadFile(testFile) + assert.NoError(t, err) + + // With structured mode and multiple directories, should create AND group at top level + assert.Contains(t, string(resultData), "operator: AND") + assert.Contains(t, string(resultData), "license: Apache-2.0") + assert.Contains(t, string(resultData), "license: MIT") + assert.Contains(t, string(resultData), "license: GPL-3.0") + assert.NotContains(t, string(resultData), "license: Not-Applicable") +} + +func TestCopyright_structuredMode_complexHierarchy(t *testing.T) { + dir := t.TempDir() + ctx := slogtest.Context(t) + + // Complex structure: multiple licenses in some directories, single in others + detectedLicenses := []license.License{ + { + Name: "Apache-2.0", + Source: "LICENSE", + Confidence: 1.0, + }, + { + Name: "MIT", + Source: "LICENSE-MIT", + Confidence: 1.0, + }, + { + Name: "GPL-3.0", + Source: "internal/LICENSE", + Confidence: 1.0, + }, + { + Name: "BSD-3-Clause", + Source: "internal/COPYING", + Confidence: 1.0, + }, + { + Name: "ISC", + Source: "vendor/LICENSE", + Confidence: 1.0, + }, + } + + diffs := []license.LicenseDiff{ + { + Path: "LICENSE", + Is: "GPL-2.0", + Should: "Apache-2.0", + }, + } + + // Copy the test data file to the temp directory + src := filepath.Join("testdata", "nolicense.yaml") + testFile := filepath.Join(dir, "nolicense.yaml") + input, err := os.ReadFile(src) + assert.NoError(t, err) + + err = os.WriteFile(testFile, input, 0644) + assert.NoError(t, err) + + rctx, err := renovate.New(renovate.WithConfig(testFile)) + assert.NoError(t, err) + + copyrightRenovator := New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(true)) + + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + resultData, err := os.ReadFile(testFile) + assert.NoError(t, err) + + result := string(resultData) + + // Should have top-level AND for multiple directories + assert.Contains(t, result, "operator: AND") + // Should have OR for internal directory (GPL-3.0 and BSD-3-Clause) + assert.Contains(t, result, "operator: OR") + // All licenses should be present + assert.Contains(t, result, "license: Apache-2.0") + assert.Contains(t, result, "license: MIT") + assert.Contains(t, result, "license: GPL-3.0") + assert.Contains(t, result, "license: BSD-3-Clause") + assert.Contains(t, result, "license: ISC") + assert.NotContains(t, result, "license: Not-Applicable") +} + +func TestCopyright_structuredMode_singleLicense(t *testing.T) { + dir := t.TempDir() + ctx := slogtest.Context(t) + + // Single license - should use simple format even in structured mode + detectedLicenses := []license.License{ + { + Name: "Apache-2.0", + Source: "LICENSE", + Confidence: 1.0, + }, + } + + diffs := []license.LicenseDiff{ + { + Path: "LICENSE", + Is: "GPL-2.0", + Should: "Apache-2.0", + }, + } + + // Copy the test data file to the temp directory + src := filepath.Join("testdata", "nolicense.yaml") + testFile := filepath.Join(dir, "nolicense.yaml") + input, err := os.ReadFile(src) + assert.NoError(t, err) + + err = os.WriteFile(testFile, input, 0644) + assert.NoError(t, err) + + rctx, err := renovate.New(renovate.WithConfig(testFile)) + assert.NoError(t, err) + + copyrightRenovator := New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(true)) + + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + resultData, err := os.ReadFile(testFile) + assert.NoError(t, err) + + result := string(resultData) + + // Should use simple format for single license + assert.Contains(t, result, "license: Apache-2.0") + assert.NotContains(t, result, "operator:") + assert.NotContains(t, result, "license: Not-Applicable") +} + +func TestCopyright_flatVsStructured(t *testing.T) { + dir := t.TempDir() + ctx := slogtest.Context(t) + + // Same license set for both tests + detectedLicenses := []license.License{ + { + Name: "Apache-2.0", + Source: "LICENSE", + Confidence: 1.0, + }, + { + Name: "MIT", + Source: "internal/COPYING", + Confidence: 1.0, + }, + } + + diffs := []license.LicenseDiff{ + { + Path: "LICENSE", + Is: "GPL-2.0", + Should: "Apache-2.0", + }, + } + + // Test flat mode + src := filepath.Join("testdata", "nolicense.yaml") + testFileFiat := filepath.Join(dir, "flat.yaml") + input, err := os.ReadFile(src) + assert.NoError(t, err) + err = os.WriteFile(testFileFiat, input, 0644) + assert.NoError(t, err) + + rctx, err := renovate.New(renovate.WithConfig(testFileFiat)) + assert.NoError(t, err) + + copyrightRenovator := New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(false)) + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + flatResult, err := os.ReadFile(testFileFiat) + assert.NoError(t, err) + + // Test structured mode + testFileStructured := filepath.Join(dir, "structured.yaml") + err = os.WriteFile(testFileStructured, input, 0644) + assert.NoError(t, err) + + rctx, err = renovate.New(renovate.WithConfig(testFileStructured)) + assert.NoError(t, err) + + copyrightRenovator = New(ctx, WithLicenses(detectedLicenses), WithDiffs(diffs), WithStructured(true)) + err = rctx.Renovate(slogtest.Context(t), copyrightRenovator) + assert.NoError(t, err) + + structuredResult, err := os.ReadFile(testFileStructured) + assert.NoError(t, err) + + // Both should contain the licenses but structured differently + flatStr := string(flatResult) + structuredStr := string(structuredResult) + + // Both should have the licenses + assert.Contains(t, flatStr, "license: Apache-2.0") + assert.Contains(t, flatStr, "license: MIT") + assert.Contains(t, structuredStr, "license: Apache-2.0") + assert.Contains(t, structuredStr, "license: MIT") + + // Flat should not have operators + assert.NotContains(t, flatStr, "operator:") + + // Structured should have AND operator (different directories) + assert.Contains(t, structuredStr, "operator: AND") +}