blob: f0f4df8beec45b46ae72ec446baf13ebac25e56e [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package readme_fuchsia
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const docURL = "https://fuchsia.dev/fuchsia-src/development/source_code/third-party-metadata"
// Validate checks if the README.fuchsia file structures contain all required fields
// and no unknown fields. It also verifies that referenced paths exist on disk.
func Validate(projectRoot string, readmes []*Readme) []error {
var errs []error
var baseDir = projectRoot
for i, r := range readmes {
filePath := r.FilePath
defaultSpan := r.BlockSpan
if defaultSpan.StartLine == 0 {
defaultSpan = LineSpan{StartLine: 1, EndLine: 1}
}
getSpan := func(key string) LineSpan {
if r.Spans != nil {
if s, ok := r.Spans[key]; ok && s.StartLine > 0 {
return s
}
}
return defaultSpan
}
addFinding := func(msg string, span LineSpan, replacements ...string) {
if span.StartLine == 0 {
span = defaultSpan
}
errs = append(errs, Finding{
FilePath: filePath,
Line: span.StartLine,
EndLine: span.EndLine,
Level: "error",
Message: msg,
Replacements: replacements,
})
}
var currentDir string
if i == 0 {
currentDir = baseDir
} else {
if r.Location != "" {
currentDir = filepath.Join(baseDir, r.Location)
if _, err := os.Stat(currentDir); os.IsNotExist(err) {
addFinding(fmt.Sprintf("[%d]: 'Location' directory does not exist: %s (%s)", i+1, r.Location, docURL), getSpan("Location"))
}
} else {
currentDir = baseDir // Fallback
}
}
// Check 1: Unknown fields
for _, uf := range r.UnknownFields {
addFinding(fmt.Sprintf("[%d]: Found unknown/invalid fields: [{Key:%s Value:%s}] (%s#syntax)", i+1, uf.Key, uf.Value, docURL), uf.Span)
}
// Check 2: Required Fields
if r.Name == "" {
addFinding(fmt.Sprintf("[%d]: Missing required field 'Name' (%s#name)", i+1, docURL), defaultSpan)
}
if r.FirstParty != "" && r.FirstParty != "yes" && r.FirstParty != "no" {
var repl []string
if strings.EqualFold(r.FirstParty, "true") {
repl = []string{"First Party: yes"}
} else if strings.EqualFold(r.FirstParty, "false") {
repl = []string{"First Party: no"}
}
addFinding(fmt.Sprintf("[%d]: Field 'First Party' has an unknown value. Required 'yes' or 'no', got %q (%s)", i+1, r.FirstParty, docURL), getSpan("First Party"), repl...)
}
if r.FirstParty != "yes" {
hasUrlAndRev := r.URL != "" && r.Revision != ""
hasCpeAndVer := r.CPEPrefix != "" && r.Version != ""
if !hasUrlAndRev && !hasCpeAndVer {
span := defaultSpan
if r.URL != "" {
span = getSpan("URL")
}
addFinding(fmt.Sprintf("[%d]: Missing required fields. Must specify either ('URL' AND 'Revision') OR ('CPEPrefix' AND 'Version') (%s#url)", i+1, docURL), span)
}
if r.SecurityCritical == "" {
addFinding(fmt.Sprintf("[%d]: Missing required field 'Security Critical' (%s#security-critical)", i+1, docURL), defaultSpan)
} else if r.SecurityCritical != "yes" && r.SecurityCritical != "no" {
var repl []string
if strings.EqualFold(r.SecurityCritical, "true") {
repl = []string{"Security Critical: yes"}
} else if strings.EqualFold(r.SecurityCritical, "false") {
repl = []string{"Security Critical: no"}
}
addFinding(fmt.Sprintf("[%d]: Field 'Security Critical' has an unknown value. Required 'yes' or 'no', got %q (%s#security-critical)", i+1, r.SecurityCritical, docURL), getSpan("Security Critical"), repl...)
}
if len(r.Licenses) == 0 {
addFinding(fmt.Sprintf("[%d]: Missing required field 'License' (%s#license)", i+1, docURL), defaultSpan)
}
if len(r.LicenseFiles) == 0 {
addFinding(fmt.Sprintf("[%d]: Missing required field 'License File'. At least one must be specified. (%s#license-file)", i+1, docURL), defaultSpan)
}
}
if i > 0 && r.Location == "" {
addFinding(fmt.Sprintf("[%d]: Missing required field 'Location' for sub-project defined after a DEPENDENCY DIVIDER (%s)", i+1, docURL), defaultSpan)
}
for _, lf := range r.LicenseFiles {
filePathOnDisk := filepath.Join(currentDir, lf)
if _, err := os.Stat(filePathOnDisk); os.IsNotExist(err) {
addFinding(fmt.Sprintf("[%d]: License File does not exist: %s (%s#license-file)", i+1, filePathOnDisk, docURL), getSpan(lf))
}
}
for _, nlf := range r.NonLicenseFiles {
filePathOnDisk := filepath.Join(currentDir, nlf)
if _, err := os.Stat(filePathOnDisk); os.IsNotExist(err) {
addFinding(fmt.Sprintf("[%d]: Non-License File does not exist: %s (%s)", i+1, filePathOnDisk, docURL), getSpan(nlf))
}
}
for _, gnf := range r.GeneratedNoticeFiles {
noticeDir := currentDir
if r.FilePath != "" {
noticeDir = filepath.Dir(r.FilePath)
if loc := filepath.Clean(r.Location); loc != "" && loc != "." {
subDir := filepath.Join(noticeDir, loc)
if stat, err := os.Stat(subDir); err == nil && stat.IsDir() {
noticeDir = subDir
}
}
}
filePathOnDisk := filepath.Join(noticeDir, gnf)
if _, err := os.Stat(filePathOnDisk); os.IsNotExist(err) {
addFinding(fmt.Sprintf("[%d]: Generated Notice File does not exist: %s (%s#license-file)", i+1, filePathOnDisk, docURL), getSpan(gnf))
}
}
}
return errs
}