| // Copyright 2021 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package compliance |
| |
| import ( |
| "fmt" |
| "io" |
| "io/fs" |
| "sort" |
| "strings" |
| "testing" |
| ) |
| |
| const ( |
| // AOSP starts a test metadata file for Android Apache-2.0 licensing. |
| AOSP = `` + |
| `package_name: "Android" |
| license_kinds: "SPDX-license-identifier-Apache-2.0" |
| license_conditions: "notice" |
| ` |
| |
| // GPL starts a test metadata file for GPL 2.0 licensing. |
| GPL = `` + |
| `package_name: "Free Software" |
| license_kinds: "SPDX-license-identifier-GPL-2.0" |
| license_conditions: "restricted" |
| ` |
| |
| // Classpath starts a test metadata file for GPL 2.0 with classpath exception licensing. |
| Classpath = `` + |
| `package_name: "Free Software" |
| license_kinds: "SPDX-license-identifier-GPL-2.0-with-classpath-exception" |
| license_conditions: "restricted" |
| ` |
| |
| // DependentModule starts a test metadata file for a module in the same package as `Classpath`. |
| DependentModule = `` + |
| `package_name: "Free Software" |
| license_kinds: "SPDX-license-identifier-MIT" |
| license_conditions: "notice" |
| ` |
| |
| // LGPL starts a test metadata file for a module with LGPL 2.0 licensing. |
| LGPL = `` + |
| `package_name: "Free Library" |
| license_kinds: "SPDX-license-identifier-LGPL-2.0" |
| license_conditions: "restricted" |
| ` |
| |
| // MPL starts a test metadata file for a module with MPL 2.0 reciprical licensing. |
| MPL = `` + |
| `package_name: "Reciprocal" |
| license_kinds: "SPDX-license-identifier-MPL-2.0" |
| license_conditions: "reciprocal" |
| ` |
| |
| // MIT starts a test metadata file for a module with generic notice (MIT) licensing. |
| MIT = `` + |
| `package_name: "Android" |
| license_kinds: "SPDX-license-identifier-MIT" |
| license_conditions: "notice" |
| ` |
| |
| // Proprietary starts a test metadata file for a module with proprietary licensing. |
| Proprietary = `` + |
| `package_name: "Android" |
| license_kinds: "legacy_proprietary" |
| license_conditions: "proprietary" |
| ` |
| |
| // ByException starts a test metadata file for a module with by_exception_only licensing. |
| ByException = `` + |
| `package_name: "Special" |
| license_kinds: "legacy_by_exception_only" |
| license_conditions: "by_exception_only" |
| ` |
| |
| ) |
| |
| var ( |
| // meta maps test file names to metadata file content without dependencies. |
| meta = map[string]string{ |
| "apacheBin.meta_lic": AOSP, |
| "apacheLib.meta_lic": AOSP, |
| "apacheContainer.meta_lic": AOSP + "is_container: true\n", |
| "dependentModule.meta_lic": DependentModule, |
| "gplWithClasspathException.meta_lic": Classpath, |
| "gplBin.meta_lic": GPL, |
| "gplLib.meta_lic": GPL, |
| "gplContainer.meta_lic": GPL + "is_container: true\n", |
| "lgplBin.meta_lic": LGPL, |
| "lgplLib.meta_lic": LGPL, |
| "mitBin.meta_lic": MIT, |
| "mitLib.meta_lic": MIT, |
| "mplBin.meta_lic": MPL, |
| "mplLib.meta_lic": MPL, |
| "proprietary.meta_lic": Proprietary, |
| "by_exception.meta_lic": ByException, |
| } |
| ) |
| |
| // toConditionList converts a test data map of condition name to origin names into a ConditionList. |
| func toConditionList(lg *LicenseGraph, conditions map[string][]string) ConditionList { |
| cl := make(ConditionList, 0) |
| for name, origins := range conditions { |
| for _, origin := range origins { |
| cl = append(cl, LicenseCondition{name, newTestNode(lg, origin)}) |
| } |
| } |
| return cl |
| } |
| |
| // newTestNode constructs a test node in the license graph. |
| func newTestNode(lg *LicenseGraph, targetName string) *TargetNode { |
| if _, ok := lg.targets[targetName]; !ok { |
| lg.targets[targetName] = &TargetNode{name: targetName} |
| } |
| return lg.targets[targetName] |
| } |
| |
| // testFS implements a test file system (fs.FS) simulated by a map from filename to []byte content. |
| type testFS map[string][]byte |
| |
| // Open implements fs.FS.Open() to open a file based on the filename. |
| func (fs *testFS) Open(name string) (fs.File, error) { |
| if _, ok := (*fs)[name]; !ok { |
| return nil, fmt.Errorf("unknown file %q", name) |
| } |
| return &testFile{fs, name, 0}, nil |
| } |
| |
| // testFile implements a test file (fs.File) based on testFS above. |
| type testFile struct { |
| fs *testFS |
| name string |
| posn int |
| } |
| |
| // Stat not implemented to obviate implementing fs.FileInfo. |
| func (f *testFile) Stat() (fs.FileInfo, error) { |
| return nil, fmt.Errorf("unimplemented") |
| } |
| |
| // Read copies bytes from the testFS map. |
| func (f *testFile) Read(b []byte) (int, error) { |
| if f.posn < 0 { |
| return 0, fmt.Errorf("file not open: %q", f.name) |
| } |
| if f.posn >= len((*f.fs)[f.name]) { |
| return 0, io.EOF |
| } |
| n := copy(b, (*f.fs)[f.name][f.posn:]) |
| f.posn += n |
| return n, nil |
| } |
| |
| // Close marks the testFile as no longer in use. |
| func (f *testFile) Close() error { |
| if f.posn < 0 { |
| return fmt.Errorf("file already closed: %q", f.name) |
| } |
| f.posn = -1 |
| return nil |
| } |
| |
| // edge describes test data edges to define test graphs. |
| type edge struct { |
| target, dep string |
| } |
| |
| // String returns a string representation of the edge. |
| func (e edge) String() string { |
| return e.target + " -> " + e.dep |
| } |
| |
| // byEdge orders edges by target then dep name then annotations. |
| type byEdge []edge |
| |
| // Len returns the count of elements in the slice. |
| func (l byEdge) Len() int { return len(l) } |
| |
| // Swap rearranges 2 elements of the slice so that each occupies the other's |
| // former position. |
| func (l byEdge) Swap(i, j int) { l[i], l[j] = l[j], l[i] } |
| |
| // Less returns true when the `i`th element is lexicographically less than |
| // the `j`th element. |
| func (l byEdge) Less(i, j int) bool { |
| if l[i].target == l[j].target { |
| return l[i].dep < l[j].dep |
| } |
| return l[i].target < l[j].target |
| } |
| |
| |
| // annotated describes annotated test data edges to define test graphs. |
| type annotated struct { |
| target, dep string |
| annotations []string |
| } |
| |
| func (e annotated) String() string { |
| if e.annotations != nil { |
| return e.target + " -> " + e.dep + " [" + strings.Join(e.annotations, ", ") + "]" |
| } |
| return e.target + " -> " + e.dep |
| } |
| |
| func (e annotated) IsEqualTo(other annotated) bool { |
| if e.target != other.target { |
| return false |
| } |
| if e.dep != other.dep { |
| return false |
| } |
| if len(e.annotations) != len(other.annotations) { |
| return false |
| } |
| a1 := append([]string{}, e.annotations...) |
| a2 := append([]string{}, other.annotations...) |
| for i := 0; i < len(a1); i++ { |
| if a1[i] != a2[i] { |
| return false |
| } |
| } |
| return true |
| } |
| |
| // toGraph converts a list of roots and a list of annotated edges into a test license graph. |
| func toGraph(stderr io.Writer, roots []string, edges []annotated) (*LicenseGraph, error) { |
| deps := make(map[string][]annotated) |
| for _, root := range roots { |
| deps[root] = []annotated{} |
| } |
| for _, edge := range edges { |
| if prev, ok := deps[edge.target]; ok { |
| deps[edge.target] = append(prev, edge) |
| } else { |
| deps[edge.target] = []annotated{edge} |
| } |
| if _, ok := deps[edge.dep]; !ok { |
| deps[edge.dep] = []annotated{} |
| } |
| } |
| fs := make(testFS) |
| for file, edges := range deps { |
| body := meta[file] |
| for _, edge := range edges { |
| body += fmt.Sprintf("deps: {\n file: %q\n", edge.dep) |
| for _, ann := range edge.annotations { |
| body += fmt.Sprintf(" annotations: %q\n", ann) |
| } |
| body += "}\n" |
| } |
| fs[file] = []byte(body) |
| } |
| |
| return ReadLicenseGraph(&fs, stderr, roots) |
| } |
| |
| |
| // byAnnotatedEdge orders edges by target then dep name then annotations. |
| type byAnnotatedEdge []annotated |
| |
| func (l byAnnotatedEdge) Len() int { return len(l) } |
| func (l byAnnotatedEdge) Swap(i, j int) { l[i], l[j] = l[j], l[i] } |
| func (l byAnnotatedEdge) Less(i, j int) bool { |
| if l[i].target == l[j].target { |
| if l[i].dep == l[j].dep { |
| ai := append([]string{}, l[i].annotations...) |
| aj := append([]string{}, l[j].annotations...) |
| sort.Strings(ai) |
| sort.Strings(aj) |
| for k := 0; k < len(ai) && k < len(aj); k++ { |
| if ai[k] == aj[k] { |
| continue |
| } |
| return ai[k] < aj[k] |
| } |
| return len(ai) < len(aj) |
| } |
| return l[i].dep < l[j].dep |
| } |
| return l[i].target < l[j].target |
| } |
| |
| // res describes test data resolutions to define test resolution sets. |
| type res struct { |
| attachesTo, actsOn, origin, condition string |
| } |
| |
| // toResolutionSet converts a list of res test data into a test resolution set. |
| func toResolutionSet(lg *LicenseGraph, data []res) *ResolutionSet { |
| rmap := make(map[*TargetNode]actionSet) |
| for _, r := range data { |
| attachesTo := newTestNode(lg, r.attachesTo) |
| actsOn := newTestNode(lg, r.actsOn) |
| origin := newTestNode(lg, r.origin) |
| if _, ok := rmap[attachesTo]; !ok { |
| rmap[attachesTo] = make(actionSet) |
| } |
| if _, ok := rmap[attachesTo][actsOn]; !ok { |
| rmap[attachesTo][actsOn] = newLicenseConditionSet() |
| } |
| rmap[attachesTo][actsOn].add(origin, r.condition) |
| } |
| return &ResolutionSet{rmap} |
| } |
| |
| type confl struct { |
| sourceNode, share, privacy string |
| } |
| |
| func toConflictList(lg *LicenseGraph, data []confl) []SourceSharePrivacyConflict { |
| result := make([]SourceSharePrivacyConflict, 0, len(data)) |
| for _, c := range data { |
| fields := strings.Split(c.share, ":") |
| oshare := fields[0] |
| cshare := fields[1] |
| fields = strings.Split(c.privacy, ":") |
| oprivacy := fields[0] |
| cprivacy := fields[1] |
| result = append(result, SourceSharePrivacyConflict{ |
| newTestNode(lg, c.sourceNode), |
| LicenseCondition{cshare, newTestNode(lg, oshare)}, |
| LicenseCondition{cprivacy, newTestNode(lg, oprivacy)}, |
| }) |
| } |
| return result |
| } |
| |
| // checkSameActions compares an actual action set to an expected action set for a test. |
| func checkSameActions(lg *LicenseGraph, asActual, asExpected actionSet, t *testing.T) { |
| rsActual := ResolutionSet{make(map[*TargetNode]actionSet)} |
| rsExpected := ResolutionSet{make(map[*TargetNode]actionSet)} |
| testNode := newTestNode(lg, "test") |
| rsActual.resolutions[testNode] = asActual |
| rsExpected.resolutions[testNode] = asExpected |
| checkSame(&rsActual, &rsExpected, t) |
| } |
| |
| // checkSame compares an actual resolution set to an expected resolution set for a test. |
| func checkSame(rsActual, rsExpected *ResolutionSet, t *testing.T) { |
| expectedTargets := rsExpected.AttachesTo() |
| sort.Sort(expectedTargets) |
| for _, target := range expectedTargets { |
| if !rsActual.AttachesToTarget(target) { |
| t.Errorf("unexpected missing target: got AttachesToTarget(%q) is false in %s, want true in %s", target.name, rsActual, rsExpected) |
| continue |
| } |
| expectedRl := rsExpected.Resolutions(target) |
| sort.Sort(expectedRl) |
| actualRl := rsActual.Resolutions(target) |
| sort.Sort(actualRl) |
| if len(expectedRl) != len(actualRl) { |
| t.Errorf("unexpected number of resolutions attach to %q: got %s with %d elements, want %s with %d elements", |
| target.name, actualRl, len(actualRl), expectedRl, len(expectedRl)) |
| continue |
| } |
| for i := 0; i < len(expectedRl); i++ { |
| if expectedRl[i].attachesTo.name != actualRl[i].attachesTo.name || expectedRl[i].actsOn.name != actualRl[i].actsOn.name { |
| t.Errorf("unexpected resolution attaches to %q at index %d: got %s, want %s", |
| target.name, i, actualRl[i].asString(), expectedRl[i].asString()) |
| continue |
| } |
| expectedConditions := expectedRl[i].Resolves().AsList() |
| actualConditions := actualRl[i].Resolves().AsList() |
| sort.Sort(expectedConditions) |
| sort.Sort(actualConditions) |
| if len(expectedConditions) != len(actualConditions) { |
| t.Errorf("unexpected number of conditions apply to %q acting on %q: got %s with %d elements, want %s with %d elements", |
| target.name, expectedRl[i].actsOn.name, |
| actualConditions, len(actualConditions), |
| expectedConditions, len(expectedConditions)) |
| continue |
| } |
| for j := 0; j < len(expectedConditions); j++ { |
| if expectedConditions[j] != actualConditions[j] { |
| t.Errorf("unexpected condition attached to %q acting on %q at index %d: got %s at index %d in %s, want %s in %s", |
| target.name, expectedRl[i].actsOn.name, i, |
| actualConditions[j].asString(":"), j, actualConditions, |
| expectedConditions[j].asString(":"), expectedConditions) |
| } |
| } |
| } |
| |
| } |
| actualTargets := rsActual.AttachesTo() |
| sort.Sort(actualTargets) |
| for i, target := range actualTargets { |
| if !rsExpected.AttachesToTarget(target) { |
| t.Errorf("unexpected target: got %q element %d in AttachesTo() %s with %d elements in %s, want %s with %d elements in %s", |
| target.name, i, actualTargets, len(actualTargets), rsActual, expectedTargets, len(expectedTargets), rsExpected) |
| } |
| } |
| } |