blob: 8f4088ab7f7a9817fa23dcd24b9a74c63ff65bd1 [file] [log] [blame]
Bob Badoura99ac622021-10-25 16:21:00 -07001// Copyright 2021 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package compliance
16
17import (
18 "fmt"
19 "io"
20 "io/fs"
21 "sort"
22 "strings"
23 "testing"
24)
25
26const (
27 // AOSP starts a test metadata file for Android Apache-2.0 licensing.
28 AOSP = `` +
29 `package_name: "Android"
30license_kinds: "SPDX-license-identifier-Apache-2.0"
31license_conditions: "notice"
32`
Bob Badour9ee7d032021-10-25 16:51:48 -070033
34 // GPL starts a test metadata file for GPL 2.0 licensing.
35 GPL = `` +
36`package_name: "Free Software"
37license_kinds: "SPDX-license-identifier-GPL-2.0"
38license_conditions: "restricted"
39`
40
41 // Classpath starts a test metadata file for GPL 2.0 with classpath exception licensing.
42 Classpath = `` +
43`package_name: "Free Software"
44license_kinds: "SPDX-license-identifier-GPL-2.0-with-classpath-exception"
45license_conditions: "restricted"
46`
47
48 // DependentModule starts a test metadata file for a module in the same package as `Classpath`.
49 DependentModule = `` +
50`package_name: "Free Software"
51license_kinds: "SPDX-license-identifier-MIT"
52license_conditions: "notice"
53`
54
55 // LGPL starts a test metadata file for a module with LGPL 2.0 licensing.
56 LGPL = `` +
57`package_name: "Free Library"
58license_kinds: "SPDX-license-identifier-LGPL-2.0"
59license_conditions: "restricted"
60`
61
62 // MPL starts a test metadata file for a module with MPL 2.0 reciprical licensing.
63 MPL = `` +
64`package_name: "Reciprocal"
65license_kinds: "SPDX-license-identifier-MPL-2.0"
66license_conditions: "reciprocal"
67`
68
69 // MIT starts a test metadata file for a module with generic notice (MIT) licensing.
70 MIT = `` +
71`package_name: "Android"
72license_kinds: "SPDX-license-identifier-MIT"
73license_conditions: "notice"
74`
75
76 // Proprietary starts a test metadata file for a module with proprietary licensing.
77 Proprietary = `` +
78`package_name: "Android"
79license_kinds: "legacy_proprietary"
80license_conditions: "proprietary"
81`
82
83 // ByException starts a test metadata file for a module with by_exception_only licensing.
84 ByException = `` +
85`package_name: "Special"
86license_kinds: "legacy_by_exception_only"
87license_conditions: "by_exception_only"
88`
Bob Badour9ee7d032021-10-25 16:51:48 -070089)
90
91var (
92 // meta maps test file names to metadata file content without dependencies.
93 meta = map[string]string{
94 "apacheBin.meta_lic": AOSP,
95 "apacheLib.meta_lic": AOSP,
96 "apacheContainer.meta_lic": AOSP + "is_container: true\n",
97 "dependentModule.meta_lic": DependentModule,
98 "gplWithClasspathException.meta_lic": Classpath,
99 "gplBin.meta_lic": GPL,
100 "gplLib.meta_lic": GPL,
101 "gplContainer.meta_lic": GPL + "is_container: true\n",
102 "lgplBin.meta_lic": LGPL,
103 "lgplLib.meta_lic": LGPL,
104 "mitBin.meta_lic": MIT,
105 "mitLib.meta_lic": MIT,
106 "mplBin.meta_lic": MPL,
107 "mplLib.meta_lic": MPL,
108 "proprietary.meta_lic": Proprietary,
109 "by_exception.meta_lic": ByException,
110 }
Bob Badoura99ac622021-10-25 16:21:00 -0700111)
112
Bob Badoura99ac622021-10-25 16:21:00 -0700113// newTestNode constructs a test node in the license graph.
114func newTestNode(lg *LicenseGraph, targetName string) *TargetNode {
Bob Badour103eb0f2022-01-10 13:50:57 -0800115 if tn, alreadyExists := lg.targets[targetName]; alreadyExists {
116 return tn
Bob Badoura99ac622021-10-25 16:21:00 -0700117 }
Bob Badour103eb0f2022-01-10 13:50:57 -0800118 tn := &TargetNode{name: targetName}
119 lg.targets[targetName] = tn
120 return tn
121}
122
123// newTestCondition constructs a test license condition in the license graph.
124func newTestCondition(lg *LicenseGraph, targetName string, conditionName string) LicenseCondition {
125 tn := newTestNode(lg, targetName)
126 cl := LicenseConditionSetFromNames(tn, conditionName).AsList()
127 if len(cl) == 0 {
128 panic(fmt.Errorf("attempt to create unrecognized condition: %q", conditionName))
129 } else if len(cl) != 1 {
130 panic(fmt.Errorf("unexpected multiple conditions from condition name: %q: got %d, want 1", conditionName, len(cl)))
131 }
132 lc := cl[0]
133 tn.licenseConditions = tn.licenseConditions.Plus(lc)
134 return lc
135}
136
137// newTestConditionSet constructs a test license condition set in the license graph.
138func newTestConditionSet(lg *LicenseGraph, targetName string, conditionName []string) LicenseConditionSet {
139 tn := newTestNode(lg, targetName)
140 cs := LicenseConditionSetFromNames(tn, conditionName...)
141 if cs.IsEmpty() {
142 panic(fmt.Errorf("attempt to create unrecognized condition: %q", conditionName))
143 }
144 tn.licenseConditions = tn.licenseConditions.Union(cs)
145 return cs
Bob Badoura99ac622021-10-25 16:21:00 -0700146}
147
148// testFS implements a test file system (fs.FS) simulated by a map from filename to []byte content.
149type testFS map[string][]byte
150
151// Open implements fs.FS.Open() to open a file based on the filename.
152func (fs *testFS) Open(name string) (fs.File, error) {
153 if _, ok := (*fs)[name]; !ok {
154 return nil, fmt.Errorf("unknown file %q", name)
155 }
156 return &testFile{fs, name, 0}, nil
157}
158
159// testFile implements a test file (fs.File) based on testFS above.
160type testFile struct {
161 fs *testFS
162 name string
163 posn int
164}
165
166// Stat not implemented to obviate implementing fs.FileInfo.
167func (f *testFile) Stat() (fs.FileInfo, error) {
168 return nil, fmt.Errorf("unimplemented")
169}
170
171// Read copies bytes from the testFS map.
172func (f *testFile) Read(b []byte) (int, error) {
173 if f.posn < 0 {
174 return 0, fmt.Errorf("file not open: %q", f.name)
175 }
176 if f.posn >= len((*f.fs)[f.name]) {
177 return 0, io.EOF
178 }
179 n := copy(b, (*f.fs)[f.name][f.posn:])
180 f.posn += n
181 return n, nil
182}
183
184// Close marks the testFile as no longer in use.
185func (f *testFile) Close() error {
186 if f.posn < 0 {
187 return fmt.Errorf("file already closed: %q", f.name)
188 }
189 f.posn = -1
190 return nil
191}
192
193// edge describes test data edges to define test graphs.
194type edge struct {
195 target, dep string
196}
197
198// String returns a string representation of the edge.
199func (e edge) String() string {
200 return e.target + " -> " + e.dep
201}
202
203// byEdge orders edges by target then dep name then annotations.
204type byEdge []edge
205
206// Len returns the count of elements in the slice.
207func (l byEdge) Len() int { return len(l) }
208
209// Swap rearranges 2 elements of the slice so that each occupies the other's
210// former position.
211func (l byEdge) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
212
213// Less returns true when the `i`th element is lexicographically less than
214// the `j`th element.
215func (l byEdge) Less(i, j int) bool {
216 if l[i].target == l[j].target {
217 return l[i].dep < l[j].dep
218 }
219 return l[i].target < l[j].target
220}
221
Bob Badour9ee7d032021-10-25 16:51:48 -0700222
223// annotated describes annotated test data edges to define test graphs.
224type annotated struct {
225 target, dep string
226 annotations []string
227}
228
229func (e annotated) String() string {
230 if e.annotations != nil {
231 return e.target + " -> " + e.dep + " [" + strings.Join(e.annotations, ", ") + "]"
232 }
233 return e.target + " -> " + e.dep
234}
235
236func (e annotated) IsEqualTo(other annotated) bool {
237 if e.target != other.target {
238 return false
239 }
240 if e.dep != other.dep {
241 return false
242 }
243 if len(e.annotations) != len(other.annotations) {
244 return false
245 }
246 a1 := append([]string{}, e.annotations...)
247 a2 := append([]string{}, other.annotations...)
248 for i := 0; i < len(a1); i++ {
249 if a1[i] != a2[i] {
250 return false
251 }
252 }
253 return true
254}
255
256// toGraph converts a list of roots and a list of annotated edges into a test license graph.
257func toGraph(stderr io.Writer, roots []string, edges []annotated) (*LicenseGraph, error) {
258 deps := make(map[string][]annotated)
259 for _, root := range roots {
260 deps[root] = []annotated{}
261 }
262 for _, edge := range edges {
263 if prev, ok := deps[edge.target]; ok {
264 deps[edge.target] = append(prev, edge)
265 } else {
266 deps[edge.target] = []annotated{edge}
267 }
268 if _, ok := deps[edge.dep]; !ok {
269 deps[edge.dep] = []annotated{}
270 }
271 }
272 fs := make(testFS)
273 for file, edges := range deps {
274 body := meta[file]
275 for _, edge := range edges {
276 body += fmt.Sprintf("deps: {\n file: %q\n", edge.dep)
277 for _, ann := range edge.annotations {
278 body += fmt.Sprintf(" annotations: %q\n", ann)
279 }
280 body += "}\n"
281 }
282 fs[file] = []byte(body)
283 }
284
285 return ReadLicenseGraph(&fs, stderr, roots)
286}
287
Bob Badour103eb0f2022-01-10 13:50:57 -0800288// logGraph outputs a representation of the graph to a test log.
289func logGraph(lg *LicenseGraph, t *testing.T) {
290 t.Logf("license graph:")
291 t.Logf(" targets:")
292 for _, target := range lg.Targets() {
293 t.Logf(" %s%s in package %q", target.Name(), target.LicenseConditions().String(), target.PackageName())
294 }
295 t.Logf(" /targets")
296 t.Logf(" edges:")
297 for _, edge := range lg.Edges() {
298 t.Logf(" %s", edge.String())
299 }
300 t.Logf(" /edges")
301 t.Logf("/license graph")
302}
Bob Badour9ee7d032021-10-25 16:51:48 -0700303
304// byAnnotatedEdge orders edges by target then dep name then annotations.
305type byAnnotatedEdge []annotated
306
307func (l byAnnotatedEdge) Len() int { return len(l) }
308func (l byAnnotatedEdge) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
309func (l byAnnotatedEdge) Less(i, j int) bool {
310 if l[i].target == l[j].target {
311 if l[i].dep == l[j].dep {
312 ai := append([]string{}, l[i].annotations...)
313 aj := append([]string{}, l[j].annotations...)
314 sort.Strings(ai)
315 sort.Strings(aj)
316 for k := 0; k < len(ai) && k < len(aj); k++ {
317 if ai[k] == aj[k] {
318 continue
319 }
320 return ai[k] < aj[k]
321 }
322 return len(ai) < len(aj)
323 }
324 return l[i].dep < l[j].dep
325 }
326 return l[i].target < l[j].target
327}
328
Bob Badour103eb0f2022-01-10 13:50:57 -0800329// act describes test data resolution actions to define test action sets.
330type act struct {
331 actsOn, origin, condition string
332}
333
334// String returns a human-readable string representing the test action.
335func (a act) String() string {
336 return fmt.Sprintf("%s{%s:%s}", a.actsOn, a.origin, a.condition)
337}
338
339// toActionSet converts a list of act test data into a test action set.
340func toActionSet(lg *LicenseGraph, data []act) ActionSet {
341 as := make(ActionSet)
342 for _, a := range data {
343 actsOn := newTestNode(lg, a.actsOn)
344 cs := newTestConditionSet(lg, a.origin, strings.Split(a.condition, "|"))
345 as[actsOn] = cs
346 }
347 return as
348}
349
Bob Badoura99ac622021-10-25 16:21:00 -0700350// res describes test data resolutions to define test resolution sets.
351type res struct {
352 attachesTo, actsOn, origin, condition string
353}
354
355// toResolutionSet converts a list of res test data into a test resolution set.
Bob Badour103eb0f2022-01-10 13:50:57 -0800356func toResolutionSet(lg *LicenseGraph, data []res) ResolutionSet {
357 rmap := make(ResolutionSet)
Bob Badoura99ac622021-10-25 16:21:00 -0700358 for _, r := range data {
359 attachesTo := newTestNode(lg, r.attachesTo)
360 actsOn := newTestNode(lg, r.actsOn)
Bob Badoura99ac622021-10-25 16:21:00 -0700361 if _, ok := rmap[attachesTo]; !ok {
Bob Badour103eb0f2022-01-10 13:50:57 -0800362 rmap[attachesTo] = make(ActionSet)
Bob Badoura99ac622021-10-25 16:21:00 -0700363 }
Bob Badour103eb0f2022-01-10 13:50:57 -0800364 cs := newTestConditionSet(lg, r.origin, strings.Split(r.condition, ":"))
365 rmap[attachesTo][actsOn] |= cs
Bob Badoura99ac622021-10-25 16:21:00 -0700366 }
Bob Badour103eb0f2022-01-10 13:50:57 -0800367 return rmap
Bob Badoura99ac622021-10-25 16:21:00 -0700368}
369
Bob Badour103eb0f2022-01-10 13:50:57 -0800370// tcond associates a target name with '|' separated string conditions.
371type tcond struct {
372 target, conditions string
373}
374
375// action represents a single element of an ActionSet for testing.
376type action struct {
377 target *TargetNode
378 cs LicenseConditionSet
379}
380
381// String returns a human-readable string representation of the action.
382func (a action) String() string {
383 return fmt.Sprintf("%s%s", a.target.Name(), a.cs.String())
384}
385
386// actionList represents an array of actions and a total order defined by
387// target name followed by license condition set.
388type actionList []action
389
390// String returns a human-readable string representation of the list.
391func (l actionList) String() string {
392 var sb strings.Builder
393 fmt.Fprintf(&sb, "[")
394 sep := ""
395 for _, a := range l {
396 fmt.Fprintf(&sb, "%s%s", sep, a.String())
397 sep = ", "
398 }
399 fmt.Fprintf(&sb, "]")
400 return sb.String()
401}
402
403// Len returns the count of elements in the slice.
404func (l actionList) Len() int { return len(l) }
405
406// Swap rearranges 2 elements of the slice so that each occupies the other's
407// former position.
408func (l actionList) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
409
410// Less returns true when the `i`th element is lexicographically less than
411// the `j`th element.
412func (l actionList) Less(i, j int) bool {
413 if l[i].target == l[j].target {
414 return l[i].cs < l[j].cs
415 }
416 return l[i].target.Name() < l[j].target.Name()
417}
418
419// asActionList represents the resolved license conditions in a license graph
420// as an actionList for comparison in a test.
421func asActionList(lg *LicenseGraph) actionList {
422 result := make(actionList, 0, len(lg.targets))
423 for _, target := range lg.targets {
424 cs := target.resolution
425 if cs.IsEmpty() {
426 continue
427 }
428 result = append(result, action{target, cs})
429 }
430 return result
431}
432
433// toActionList converts an array of tcond into an actionList for comparison
434// in a test.
435func toActionList(lg *LicenseGraph, actions []tcond) actionList {
436 result := make(actionList, 0, len(actions))
437 for _, actn := range actions {
438 target := newTestNode(lg, actn.target)
439 cs := NewLicenseConditionSet()
440 for _, name := range strings.Split(actn.conditions, "|") {
441 lc, ok := RecognizedConditionNames[name]
442 if !ok {
443 panic(fmt.Errorf("Unrecognized test condition name: %q", name))
444 }
445 cs = cs.Plus(lc)
446 }
447 result = append(result, action{target, cs})
448 }
449 return result
450}
451
452// confl defines test data for a SourceSharePrivacyConflict as a target name,
453// source condition name, privacy condition name triple.
Bob Badour9ee7d032021-10-25 16:51:48 -0700454type confl struct {
455 sourceNode, share, privacy string
456}
457
Bob Badour103eb0f2022-01-10 13:50:57 -0800458// toConflictList converts confl test data into an array of
459// SourceSharePrivacyConflict for comparison in a test.
Bob Badour9ee7d032021-10-25 16:51:48 -0700460func toConflictList(lg *LicenseGraph, data []confl) []SourceSharePrivacyConflict {
461 result := make([]SourceSharePrivacyConflict, 0, len(data))
462 for _, c := range data {
463 fields := strings.Split(c.share, ":")
464 oshare := fields[0]
465 cshare := fields[1]
466 fields = strings.Split(c.privacy, ":")
467 oprivacy := fields[0]
468 cprivacy := fields[1]
469 result = append(result, SourceSharePrivacyConflict{
470 newTestNode(lg, c.sourceNode),
Bob Badour103eb0f2022-01-10 13:50:57 -0800471 newTestCondition(lg, oshare, cshare),
472 newTestCondition(lg, oprivacy, cprivacy),
Bob Badour9ee7d032021-10-25 16:51:48 -0700473 })
474 }
475 return result
476}
477
Bob Badoura99ac622021-10-25 16:21:00 -0700478// checkSameActions compares an actual action set to an expected action set for a test.
Bob Badour103eb0f2022-01-10 13:50:57 -0800479func checkSameActions(lg *LicenseGraph, asActual, asExpected ActionSet, t *testing.T) {
480 rsActual := make(ResolutionSet)
481 rsExpected := make(ResolutionSet)
Bob Badoura99ac622021-10-25 16:21:00 -0700482 testNode := newTestNode(lg, "test")
Bob Badour103eb0f2022-01-10 13:50:57 -0800483 rsActual[testNode] = asActual
484 rsExpected[testNode] = asExpected
485 checkSame(rsActual, rsExpected, t)
Bob Badoura99ac622021-10-25 16:21:00 -0700486}
487
488// checkSame compares an actual resolution set to an expected resolution set for a test.
Bob Badour103eb0f2022-01-10 13:50:57 -0800489func checkSame(rsActual, rsExpected ResolutionSet, t *testing.T) {
490 t.Logf("actual resolution set: %s", rsActual.String())
491 t.Logf("expected resolution set: %s", rsExpected.String())
492
493 actualTargets := rsActual.AttachesTo()
494 sort.Sort(actualTargets)
495
Bob Badoura99ac622021-10-25 16:21:00 -0700496 expectedTargets := rsExpected.AttachesTo()
497 sort.Sort(expectedTargets)
Bob Badour103eb0f2022-01-10 13:50:57 -0800498
499 t.Logf("actual targets: %s", actualTargets.String())
500 t.Logf("expected targets: %s", expectedTargets.String())
501
Bob Badoura99ac622021-10-25 16:21:00 -0700502 for _, target := range expectedTargets {
503 if !rsActual.AttachesToTarget(target) {
Bob Badour103eb0f2022-01-10 13:50:57 -0800504 t.Errorf("unexpected missing target: got AttachesToTarget(%q) is false, want true", target.name)
Bob Badoura99ac622021-10-25 16:21:00 -0700505 continue
506 }
507 expectedRl := rsExpected.Resolutions(target)
508 sort.Sort(expectedRl)
509 actualRl := rsActual.Resolutions(target)
510 sort.Sort(actualRl)
511 if len(expectedRl) != len(actualRl) {
Bob Badour103eb0f2022-01-10 13:50:57 -0800512 t.Errorf("unexpected number of resolutions attach to %q: %d elements, %d elements",
513 target.name, len(actualRl), len(expectedRl))
Bob Badoura99ac622021-10-25 16:21:00 -0700514 continue
515 }
516 for i := 0; i < len(expectedRl); i++ {
517 if expectedRl[i].attachesTo.name != actualRl[i].attachesTo.name || expectedRl[i].actsOn.name != actualRl[i].actsOn.name {
518 t.Errorf("unexpected resolution attaches to %q at index %d: got %s, want %s",
519 target.name, i, actualRl[i].asString(), expectedRl[i].asString())
520 continue
521 }
Bob Badour103eb0f2022-01-10 13:50:57 -0800522 expectedConditions := expectedRl[i].Resolves()
523 actualConditions := actualRl[i].Resolves()
524 if expectedConditions != actualConditions {
525 t.Errorf("unexpected conditions apply to %q acting on %q: got %04x with names %s, want %04x with names %s",
Bob Badoura99ac622021-10-25 16:21:00 -0700526 target.name, expectedRl[i].actsOn.name,
Bob Badour103eb0f2022-01-10 13:50:57 -0800527 actualConditions, actualConditions.Names(),
528 expectedConditions, expectedConditions.Names())
Bob Badoura99ac622021-10-25 16:21:00 -0700529 continue
530 }
Bob Badoura99ac622021-10-25 16:21:00 -0700531 }
532
533 }
Bob Badour103eb0f2022-01-10 13:50:57 -0800534 for _, target := range actualTargets {
535 if !rsExpected.AttachesToTarget(target) {
536 t.Errorf("unexpected extra target: got expected.AttachesTo(%q) is false, want true", target.name)
537 }
538 }
539}
540
541// checkResolvesActions compares an actual action set to an expected action set for a test verifying the actual set
542// resolves all of the expected conditions.
543func checkResolvesActions(lg *LicenseGraph, asActual, asExpected ActionSet, t *testing.T) {
544 rsActual := make(ResolutionSet)
545 rsExpected := make(ResolutionSet)
546 testNode := newTestNode(lg, "test")
547 rsActual[testNode] = asActual
548 rsExpected[testNode] = asExpected
549 checkResolves(rsActual, rsExpected, t)
550}
551
552// checkResolves compares an actual resolution set to an expected resolution set for a test verifying the actual set
553// resolves all of the expected conditions.
554func checkResolves(rsActual, rsExpected ResolutionSet, t *testing.T) {
555 t.Logf("actual resolution set: %s", rsActual.String())
556 t.Logf("expected resolution set: %s", rsExpected.String())
557
Bob Badoura99ac622021-10-25 16:21:00 -0700558 actualTargets := rsActual.AttachesTo()
559 sort.Sort(actualTargets)
Bob Badour103eb0f2022-01-10 13:50:57 -0800560
561 expectedTargets := rsExpected.AttachesTo()
562 sort.Sort(expectedTargets)
563
564 t.Logf("actual targets: %s", actualTargets.String())
565 t.Logf("expected targets: %s", expectedTargets.String())
566
567 for _, target := range expectedTargets {
568 if !rsActual.AttachesToTarget(target) {
569 t.Errorf("unexpected missing target: got AttachesToTarget(%q) is false, want true", target.name)
570 continue
571 }
572 expectedRl := rsExpected.Resolutions(target)
573 sort.Sort(expectedRl)
574 actualRl := rsActual.Resolutions(target)
575 sort.Sort(actualRl)
576 if len(expectedRl) != len(actualRl) {
577 t.Errorf("unexpected number of resolutions attach to %q: %d elements, %d elements",
578 target.name, len(actualRl), len(expectedRl))
579 continue
580 }
581 for i := 0; i < len(expectedRl); i++ {
582 if expectedRl[i].attachesTo.name != actualRl[i].attachesTo.name || expectedRl[i].actsOn.name != actualRl[i].actsOn.name {
583 t.Errorf("unexpected resolution attaches to %q at index %d: got %s, want %s",
584 target.name, i, actualRl[i].asString(), expectedRl[i].asString())
585 continue
586 }
587 expectedConditions := expectedRl[i].Resolves()
588 actualConditions := actualRl[i].Resolves()
589 if expectedConditions != (expectedConditions & actualConditions) {
590 t.Errorf("expected conditions missing from %q acting on %q: got %04x with names %s, want %04x with names %s",
591 target.name, expectedRl[i].actsOn.name,
592 actualConditions, actualConditions.Names(),
593 expectedConditions, expectedConditions.Names())
594 continue
595 }
596 }
597
598 }
599 for _, target := range actualTargets {
Bob Badoura99ac622021-10-25 16:21:00 -0700600 if !rsExpected.AttachesToTarget(target) {
Bob Badour103eb0f2022-01-10 13:50:57 -0800601 t.Errorf("unexpected extra target: got expected.AttachesTo(%q) is false, want true", target.name)
Bob Badoura99ac622021-10-25 16:21:00 -0700602 }
603 }
604}