Refactor projectmetadata into separate package.
Replace regular expressions to extract fields from a text proto with
and actual parsed protobuf.
Refactor TestFS into its own package, and implement StatFS.
Test: m droid dist cts alllicensemetadata
Test: repo forall -c 'echo -n "$REPO_PATH " && $ANDROID_BUILD_TOP/out/host/linux-x86/bin/compliance_checkmetadata . 2>&1' | fgrep -v PASS
Change-Id: Icd17a6a2b6a4e2b6ffded48e964b9c9d6e4d64d6
diff --git a/tools/compliance/projectmetadata/Android.bp b/tools/compliance/projectmetadata/Android.bp
new file mode 100644
index 0000000..dccff76
--- /dev/null
+++ b/tools/compliance/projectmetadata/Android.bp
@@ -0,0 +1,34 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+bootstrap_go_package {
+ name: "projectmetadata-module",
+ srcs: [
+ "projectmetadata.go",
+ ],
+ deps: [
+ "compliance-test-fs-module",
+ "golang-protobuf-proto",
+ "golang-protobuf-encoding-prototext",
+ "project_metadata_proto",
+ ],
+ testSrcs: [
+ "projectmetadata_test.go",
+ ],
+ pkgPath: "android/soong/tools/compliance/projectmetadata",
+}
diff --git a/tools/compliance/projectmetadata/projectmetadata.go b/tools/compliance/projectmetadata/projectmetadata.go
new file mode 100644
index 0000000..b31413d
--- /dev/null
+++ b/tools/compliance/projectmetadata/projectmetadata.go
@@ -0,0 +1,209 @@
+// Copyright 2022 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 projectmetadata
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "android/soong/compliance/project_metadata_proto"
+
+ "google.golang.org/protobuf/encoding/prototext"
+)
+
+var (
+ // ConcurrentReaders is the size of the task pool for limiting resource usage e.g. open files.
+ ConcurrentReaders = 5
+)
+
+// ProjectMetadata contains the METADATA for a git project.
+type ProjectMetadata struct {
+ proto project_metadata_proto.Metadata
+
+ // project is the path to the directory containing the METADATA file.
+ project string
+}
+
+// String returns a string representation of the metadata for error messages.
+func (pm *ProjectMetadata) String() string {
+ return fmt.Sprintf("project: %q\n%s", pm.project, pm.proto.String())
+}
+
+// VersionedName returns the name of the project including the version if any.
+func (pm *ProjectMetadata) VersionedName() string {
+ name := pm.proto.GetName()
+ if name != "" {
+ tp := pm.proto.GetThirdParty()
+ if tp != nil {
+ version := tp.GetVersion()
+ if version != "" {
+ if version[0] == 'v' || version[0] == 'V' {
+ return name + "_" + version
+ } else {
+ return name + "_v_" + version
+ }
+ }
+ }
+ return name
+ }
+ return pm.proto.GetDescription()
+}
+
+// projectIndex describes a project to be read; after `wg.Wait()`, will contain either
+// a `ProjectMetadata`, pm (can be nil even without error), or a non-nil `err`.
+type projectIndex struct {
+ project string
+ pm *ProjectMetadata
+ err error
+ done chan struct{}
+}
+
+// finish marks the task to read the `projectIndex` completed.
+func (pi *projectIndex) finish() {
+ close(pi.done)
+}
+
+// wait suspends execution until the `projectIndex` task completes.
+func (pi *projectIndex) wait() {
+ <-pi.done
+}
+
+// Index reads and caches ProjectMetadata (thread safe)
+type Index struct {
+ // projecs maps project name to a wait group if read has already started, and
+ // to a `ProjectMetadata` or to an `error` after the read completes.
+ projects sync.Map
+
+ // task provides a fixed-size task pool to limit concurrent open files etc.
+ task chan bool
+
+ // rootFS locates the root of the file system from which to read the files.
+ rootFS fs.FS
+}
+
+// NewIndex constructs a project metadata `Index` for the given file system.
+func NewIndex(rootFS fs.FS) *Index {
+ ix := &Index{task: make(chan bool, ConcurrentReaders), rootFS: rootFS}
+ for i := 0; i < ConcurrentReaders; i++ {
+ ix.task <- true
+ }
+ return ix
+}
+
+// MetadataForProjects returns 0..n ProjectMetadata for n `projects`, or an error.
+// Each project that has a METADATA.android or a METADATA file in the root of the project will have
+// a corresponding ProjectMetadata in the result. Projects with neither file get skipped. A nil
+// result with no error indicates none of the given `projects` has a METADATA file.
+// (thread safe -- can be called concurrently from multiple goroutines)
+func (ix *Index) MetadataForProjects(projects ...string) ([]*ProjectMetadata, error) {
+ if ConcurrentReaders < 1 {
+ return nil, fmt.Errorf("need at least one task in project metadata pool")
+ }
+ if len(projects) == 0 {
+ return nil, nil
+ }
+ // Identify the projects that have never been read
+ projectsToRead := make([]*projectIndex, 0, len(projects))
+ projectIndexes := make([]*projectIndex, 0, len(projects))
+ for _, p := range projects {
+ pi, loaded := ix.projects.LoadOrStore(p, &projectIndex{project: p, done: make(chan struct{})})
+ if !loaded {
+ projectsToRead = append(projectsToRead, pi.(*projectIndex))
+ }
+ projectIndexes = append(projectIndexes, pi.(*projectIndex))
+ }
+ // findMeta locates and reads the appropriate METADATA file, if any.
+ findMeta := func(pi *projectIndex) {
+ <-ix.task
+ defer func() {
+ ix.task <- true
+ pi.finish()
+ }()
+
+ // Support METADATA.android for projects that already have a different sort of METADATA file.
+ path := filepath.Join(pi.project, "METADATA.android")
+ fi, err := fs.Stat(ix.rootFS, path)
+ if err == nil {
+ if fi.Mode().IsRegular() {
+ ix.readMetadataFile(pi, path)
+ return
+ }
+ }
+ // No METADATA.android try METADATA file.
+ path = filepath.Join(pi.project, "METADATA")
+ fi, err = fs.Stat(ix.rootFS, path)
+ if err == nil {
+ if fi.Mode().IsRegular() {
+ ix.readMetadataFile(pi, path)
+ return
+ }
+ }
+ // no METADATA file exists -- leave nil and finish
+ }
+ // Look for the METADATA files to read, and record any missing.
+ for _, p := range projectsToRead {
+ go findMeta(p)
+ }
+ // Wait until all of the projects have been read.
+ var msg strings.Builder
+ result := make([]*ProjectMetadata, 0, len(projects))
+ for _, pi := range projectIndexes {
+ pi.wait()
+ // Combine any errors into a single error.
+ if pi.err != nil {
+ fmt.Fprintf(&msg, " %v\n", pi.err)
+ } else if pi.pm != nil {
+ result = append(result, pi.pm)
+ }
+ }
+ if msg.Len() > 0 {
+ return nil, fmt.Errorf("error reading project(s):\n%s", msg.String())
+ }
+ if len(result) == 0 {
+ return nil, nil
+ }
+ return result, nil
+}
+
+// readMetadataFile tries to read and parse a METADATA file at `path` for `project`.
+func (ix *Index) readMetadataFile(pi *projectIndex, path string) {
+ f, err := ix.rootFS.Open(path)
+ if err != nil {
+ pi.err = fmt.Errorf("error opening project %q metadata %q: %w", pi.project, path, err)
+ return
+ }
+
+ // read the file
+ data, err := io.ReadAll(f)
+ if err != nil {
+ pi.err = fmt.Errorf("error reading project %q metadata %q: %w", pi.project, path, err)
+ return
+ }
+ f.Close()
+
+ uo := prototext.UnmarshalOptions{DiscardUnknown: true}
+ pm := &ProjectMetadata{project: pi.project}
+ err = uo.Unmarshal(data, &pm.proto)
+ if err != nil {
+ pi.err = fmt.Errorf("error in project %q metadata %q: %w", pi.project, path, err)
+ return
+ }
+
+ pi.pm = pm
+}
diff --git a/tools/compliance/projectmetadata/projectmetadata_test.go b/tools/compliance/projectmetadata/projectmetadata_test.go
new file mode 100644
index 0000000..1e4256f
--- /dev/null
+++ b/tools/compliance/projectmetadata/projectmetadata_test.go
@@ -0,0 +1,294 @@
+// Copyright 2022 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 projectmetadata
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "android/soong/tools/compliance/testfs"
+)
+
+const (
+ // EMPTY represents a METADATA file with no recognized fields
+ EMPTY = ``
+
+ // INVALID_NAME represents a METADATA file with the wrong type of name
+ INVALID_NAME = `name: a library\n`
+
+ // INVALID_DESCRIPTION represents a METADATA file with the wrong type of description
+ INVALID_DESCRIPTION = `description: unquoted text\n`
+
+ // INVALID_VERSION represents a METADATA file with the wrong type of version
+ INVALID_VERSION = `third_party { version: 1 }`
+
+ // MY_LIB_1_0 represents a METADATA file for version 1.0 of mylib
+ MY_LIB_1_0 = `name: "mylib" description: "my library" third_party { version: "1.0" }`
+
+ // NO_NAME_0_1 represents a METADATA file with a description but no name
+ NO_NAME_0_1 = `description: "my library" third_party { version: "0.1" }`
+)
+
+func TestReadMetadataForProjects(t *testing.T) {
+ tests := []struct {
+ name string
+ fs *testfs.TestFS
+ projects []string
+ expectedError string
+ expected []pmeta
+ }{
+ {
+ name: "trivial",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte("name: \"Android\"\n"),
+ },
+ projects: []string{"/a"},
+ expected: []pmeta{{project: "/a", versionedName: "Android"}},
+ },
+ {
+ name: "versioned",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(MY_LIB_1_0),
+ },
+ projects: []string{"/a"},
+ expected: []pmeta{{project: "/a", versionedName: "mylib_v_1.0"}},
+ },
+ {
+ name: "versioneddesc",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(NO_NAME_0_1),
+ },
+ projects: []string{"/a"},
+ expected: []pmeta{{project: "/a", versionedName: "my library"}},
+ },
+ {
+ name: "unterminated",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte("name: \"Android\n"),
+ },
+ projects: []string{"/a"},
+ expectedError: `invalid character '\n' in string`,
+ },
+ {
+ name: "abc",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(EMPTY),
+ "/b/METADATA": []byte(MY_LIB_1_0),
+ "/c/METADATA": []byte(NO_NAME_0_1),
+ },
+ projects: []string{"/a", "/b", "/c"},
+ expected: []pmeta{
+ {project: "/a", versionedName: ""},
+ {project: "/b", versionedName: "mylib_v_1.0"},
+ {project: "/c", versionedName: "my library"},
+ },
+ },
+ {
+ name: "ab",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(EMPTY),
+ "/b/METADATA": []byte(MY_LIB_1_0),
+ },
+ projects: []string{"/a", "/b", "/c"},
+ expected: []pmeta{
+ {project: "/a", versionedName: ""},
+ {project: "/b", versionedName: "mylib_v_1.0"},
+ },
+ },
+ {
+ name: "ac",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(EMPTY),
+ "/c/METADATA": []byte(NO_NAME_0_1),
+ },
+ projects: []string{"/a", "/b", "/c"},
+ expected: []pmeta{
+ {project: "/a", versionedName: ""},
+ {project: "/c", versionedName: "my library"},
+ },
+ },
+ {
+ name: "bc",
+ fs: &testfs.TestFS{
+ "/b/METADATA": []byte(MY_LIB_1_0),
+ "/c/METADATA": []byte(NO_NAME_0_1),
+ },
+ projects: []string{"/a", "/b", "/c"},
+ expected: []pmeta{
+ {project: "/b", versionedName: "mylib_v_1.0"},
+ {project: "/c", versionedName: "my library"},
+ },
+ },
+ {
+ name: "wrongnametype",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_NAME),
+ },
+ projects: []string{"/a"},
+ expectedError: `invalid value for string type`,
+ },
+ {
+ name: "wrongdescriptiontype",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_DESCRIPTION),
+ },
+ projects: []string{"/a"},
+ expectedError: `invalid value for string type`,
+ },
+ {
+ name: "wrongversiontype",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_VERSION),
+ },
+ projects: []string{"/a"},
+ expectedError: `invalid value for string type`,
+ },
+ {
+ name: "wrongtype",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_NAME + INVALID_DESCRIPTION + INVALID_VERSION),
+ },
+ projects: []string{"/a"},
+ expectedError: `invalid value for string type`,
+ },
+ {
+ name: "empty",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(EMPTY),
+ },
+ projects: []string{"/a"},
+ expected: []pmeta{{project: "/a", versionedName: ""}},
+ },
+ {
+ name: "emptyother",
+ fs: &testfs.TestFS{
+ "/a/METADATA.bp": []byte(EMPTY),
+ },
+ projects: []string{"/a"},
+ },
+ {
+ name: "emptyfs",
+ fs: &testfs.TestFS{},
+ projects: []string{"/a"},
+ },
+ {
+ name: "override",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_NAME + INVALID_DESCRIPTION + INVALID_VERSION),
+ "/a/METADATA.android": []byte(MY_LIB_1_0),
+ },
+ projects: []string{"/a"},
+ expected: []pmeta{{project: "/a", versionedName: "mylib_v_1.0"}},
+ },
+ {
+ name: "enchilada",
+ fs: &testfs.TestFS{
+ "/a/METADATA": []byte(INVALID_NAME + INVALID_DESCRIPTION + INVALID_VERSION),
+ "/a/METADATA.android": []byte(EMPTY),
+ "/b/METADATA": []byte(MY_LIB_1_0),
+ "/c/METADATA": []byte(NO_NAME_0_1),
+ },
+ projects: []string{"/a", "/b", "/c"},
+ expected: []pmeta{
+ {project: "/a", versionedName: ""},
+ {project: "/b", versionedName: "mylib_v_1.0"},
+ {project: "/c", versionedName: "my library"},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ix := NewIndex(tt.fs)
+ pms, err := ix.MetadataForProjects(tt.projects...)
+ if err != nil {
+ if len(tt.expectedError) == 0 {
+ t.Errorf("unexpected error: got %s, want no error", err)
+ } else if !strings.Contains(err.Error(), tt.expectedError) {
+ t.Errorf("unexpected error: got %s, want %q", err, tt.expectedError)
+ }
+ return
+ }
+ t.Logf("actual %d project metadata", len(pms))
+ for _, pm := range pms {
+ t.Logf(" %v", pm.String())
+ }
+ t.Logf("expected %d project metadata", len(tt.expected))
+ for _, pm := range tt.expected {
+ t.Logf(" %s", pm.String())
+ }
+ if len(tt.expectedError) > 0 {
+ t.Errorf("unexpected success: got no error, want %q err", tt.expectedError)
+ return
+ }
+ if len(pms) != len(tt.expected) {
+ t.Errorf("missing project metadata: got %d project metadata, want %d", len(pms), len(tt.expected))
+ }
+ for i := 0; i < len(pms) && i < len(tt.expected); i++ {
+ if msg := tt.expected[i].difference(pms[i]); msg != "" {
+ t.Errorf("unexpected metadata starting at index %d: %s", i, msg)
+ return
+ }
+ }
+ if len(pms) < len(tt.expected) {
+ t.Errorf("missing metadata starting at index %d: got nothing, want %s", len(pms), tt.expected[len(pms)].String())
+ }
+ if len(tt.expected) < len(pms) {
+ t.Errorf("unexpected metadata starting at index %d: got %s, want nothing", len(tt.expected), pms[len(tt.expected)].String())
+ }
+ })
+ }
+}
+
+type pmeta struct {
+ project string
+ versionedName string
+}
+
+func (pm pmeta) String() string {
+ return fmt.Sprintf("project: %q versionedName: %q\n", pm.project, pm.versionedName)
+}
+
+func (pm pmeta) equals(other *ProjectMetadata) bool {
+ if pm.project != other.project {
+ return false
+ }
+ if pm.versionedName != other.VersionedName() {
+ return false
+ }
+ return true
+}
+
+func (pm pmeta) difference(other *ProjectMetadata) string {
+ if pm.equals(other) {
+ return ""
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "got")
+ if pm.project != other.project {
+ fmt.Fprintf(&sb, " project: %q", other.project)
+ }
+ if pm.versionedName != other.VersionedName() {
+ fmt.Fprintf(&sb, " versionedName: %q", other.VersionedName())
+ }
+ fmt.Fprintf(&sb, ", want")
+ if pm.project != other.project {
+ fmt.Fprintf(&sb, " project: %q", pm.project)
+ }
+ if pm.versionedName != other.VersionedName() {
+ fmt.Fprintf(&sb, " versionedName: %q", pm.versionedName)
+ }
+ return sb.String()
+}