blob: 534158f7cd5665a51bc304f696cfc6366ef8f883 [file] [log] [blame]
// Copyright 2023 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
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"go.fuchsia.dev/fuchsia/tools/check-licenses/file"
)
const (
singleLicenseFile = file.SingleLicense
GitRevision = "${GIT_REVISION}"
)
var (
AllReadmes = map[string]*Readme{}
knownDirectives = map[string]bool{
"Name": true,
"License": true,
" -> License Classifications": true,
"License File": true,
"License File Format": true,
" -> License File Format": true,
"License File URL": true,
" -> License File URL": true,
" -> License Exceptions": true,
" -> License Skip Reason": true,
" -> Notes": true,
"Version": true,
"Modifications": true,
"Local Modifications": true,
"Description": true,
"URL": true,
"Upstream Git": true,
"Security Critical": true,
// Unused or non-standard
"Files": true,
"Upstream": true,
"Shipped": true,
"Upstream git": true,
"License Android Compatible": true,
"Versions": true,
"Source": true,
"Short Name": true,
"Git Commit": true,
"Commit": true,
"Revision": true,
"Date": true,
"Deprecated": true,
// DEPRECATED: soft transition
"check-licenses": true,
"#License File": true,
"LICENSE": true,
}
)
// Readme struct follows the format of README.fuchsia files.
// For more info, see the following article:
//
// https://fuchsia.dev/fuchsia-src/development/source_code/third-party-metadata
type Readme struct {
Name string `json:"name"`
URL string `json:"url"`
Version string `json:"version"`
Licenses []*ReadmeLicense `json:"licenseInfo"`
UpstreamGit string `json:"upstreamGit"`
SecurityCritical bool `json:"securityCritical"`
Description string `json:"description"`
LocalModifications string `json:"localModifications"`
// Custom fields for the Fuchsia repository.
ProjectRoot string `json:"projectRoot"`
ReadmePath string `json:"readmePath"`
RegularFileType file.FileType
// For Compliance worksheet
ShouldBeDisplayed bool
SourceCodeIncluded bool
// Logging
MalformedLines []string `json:"malformedLines"`
OtherFields []*Other `json:"otherFields"`
OtherMultiLineFields []*Other `json:"otherMultiLineFields"`
// Internal field used to note that this README.fuchsia file is located
// in a check-licenses "assets" directory.
IsAssetDirReadme bool `json:"isAssetDirReadme"`
}
// Several directives specify information about a given license file.
// Group them together in this ReadmeLicense data structure.
type ReadmeLicense struct {
LicenseClassifications string `json:"licenseClassifications"`
LicenseFile string `json:"licenseFile"`
LicenseFileFormat string `json:"licenseFileFormat"`
LicenseFileURL string `json:"licenseFileURL"`
LicenseSkipReason string `json:"licenseSkipReason"`
LicenseExceptions string `json:"licenseExceptions"`
LicenseNotes string `json:"licenseNotes"`
LicenseFileRef *file.File
}
// We don't care about many fields that can be listed in a README.fuchsia file,
// but we shouldn't delete them when reformatting the files.
// Keep track of them in a generic "other" data structure.
type Other struct {
Directive string `json:"directive"`
Value string `json:"value"`
}
// Create a Readme object from a path on the filesystem.
//
// Certain projects in the repo do not currently (and never will) provide
// a README.fuchsia file. This generates a Readme object by using other
// information about the project.
func NewReadmeCustom(projectRoot string) (*Readme, error) {
switch {
case strings.Contains(projectRoot, "dart-pkg"):
return NewDartPkgReadme(projectRoot)
case strings.Contains(projectRoot, "golibs") || strings.Contains(projectRoot, "syzkaller"):
return NewGolibReadme(projectRoot)
case strings.Contains(projectRoot, "rust_crates"):
return NewRustCrateReadme(projectRoot)
default:
return nil, fmt.Errorf("Custom readme generation for project root [%s] is not supported", projectRoot)
}
}
// Create a Readme object from a README.* file on the filesystem.
func NewReadmeFromFile(readmePath string) (*Readme, error) {
return NewReadmeFromFileCustomLocation(filepath.Dir(readmePath), readmePath)
}
// Create a Readme object from a README.* file on the filesystem.
// Second parameter is the readme file path. This is helpful when creating projects
// from README.fuchsia files that are not located in the root directory
// of the project.
func NewReadmeFromFileCustomLocation(projectRoot, readmePath string) (*Readme, error) {
if _, err := os.Stat(filepath.Dir(readmePath)); os.IsNotExist(err) {
return nil, err
}
if _, err := os.Stat(projectRoot); os.IsNotExist(err) {
return nil, err
}
f, err := os.Open(readmePath)
if err != nil {
return nil, fmt.Errorf("newReadme(%s): %w\n", readmePath, err)
}
defer f.Close()
r, err := NewReadme(f, projectRoot, readmePath)
if err != nil {
return nil, err
}
r.IsAssetDirReadme = true
return r, nil
}
// NewReadme creates a new Readme object from an io.Reader.
func NewReadme(r io.Reader, projectRoot string, readmePath string) (*Readme, error) {
if r, ok := AllReadmes[projectRoot]; ok {
return r, nil
}
readme := &Readme{
Licenses: make([]*ReadmeLicense, 0),
MalformedLines: make([]string, 0),
ProjectRoot: projectRoot,
ReadmePath: readmePath,
}
s := bufio.NewScanner(r)
s.Split(bufio.ScanLines)
line := ""
getNextLine := true
for {
if getNextLine {
if !s.Scan() {
break
}
} else {
getNextLine = true
}
line = s.Text()
if len(strings.TrimSpace(line)) == 0 {
continue
}
directive, value, err := parseReadmeLine(line)
if err != nil {
readme.MalformedLines = append(readme.MalformedLines, line)
continue
}
switch directive {
case "Name":
readme.Name = value
case "Source", "URL":
readme.URL = value
case "Versions", "Version":
readme.Version = value
case "LICENSE", "License", " -> License Classifications":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseClassifications: value})
case "License File Format", " -> License File Format":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileFormat: value})
case "License File":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFile: value})
case " -> License File URL", "License File URL":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileURL: value})
case " -> License Exceptions":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseExceptions: value})
case " -> License Skip Reason":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseSkipReason: value})
case " -> Notes":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseNotes: value})
case "Upstream git", "Upstream Git":
readme.UpstreamGit = value
case "Security Critical":
readme.SecurityCritical = strings.ToLower(value) == "yes"
case "Description":
directive, value, readme.Description, getNextLine = parseReadmeMultiLineDirective(s, value)
case "Modifications", "Local Modifications":
directive, value, readme.LocalModifications, getNextLine = parseReadmeMultiLineDirective(s, value)
// Deprecated but still in use currently
case "check-licenses":
// Used to specify license format
switch value {
case "license format: multi_license_google":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileFormat: "Multi License Google"})
case "license format: multi_license_chromium":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileFormat: "Multi License Chromium"})
case "license format: multi_license_flutter":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileFormat: "Multi License Flutter"})
case "license format: multi_license_android":
readme.ProcessReadmeLicense(&ReadmeLicense{LicenseFileFormat: "Multi License Android"})
case "file format: copyright_header": //do nothing
default:
return nil, fmt.Errorf("Unknown deprecated license directive: %s: %s\n", value, readmePath)
}
// Unused multi-line directives still need to be processed here.
case "Deprecated":
getNextLine = false
// We still want to record this segment in the output README file.
_, _, s, _ := parseReadmeMultiLineDirective(s, value)
readme.OtherMultiLineFields = append(readme.OtherMultiLineFields, &Other{"Deprecated", s})
// Empty space is OK
case "":
// Do nothing.
default:
o := Other{
Directive: directive,
Value: value,
}
readme.OtherFields = append(readme.OtherFields, &o)
}
}
// Loop through all license files that are listed in this Readme.
for _, l := range readme.Licenses {
// If this license file does not already have a URL, generate one now.
if l.LicenseFileURL == "" {
if url, err := readme.getLicenseURLForPath(l.LicenseFile); err != nil {
l.LicenseFileURL = url
}
}
if strings.TrimSpace(l.LicenseFileFormat) == "" {
l.LicenseFileFormat = string(file.SingleLicense)
}
// Also attempt to load the license file into memory.
if l.LicenseFileRef == nil && l.LicenseFile != "" {
path := filepath.Join(projectRoot, l.LicenseFile)
fileFormat := file.FileTypes[l.LicenseFileFormat]
f, err := file.LoadFile(path, fileFormat, readme.Name)
if err == nil {
l.LicenseFileRef = f
}
}
}
AllReadmes[projectRoot] = readme
return readme, nil
}
// License file directives can be listed in any order.
// e.g. URL info may come before or after information about where the file lives on the filesystem.
// We solve this by merging ReadmeLicense structs together that don't have overlapping information.
func (r *Readme) ProcessReadmeLicense(rl *ReadmeLicense) {
var last *ReadmeLicense
l := len(r.Licenses)
if l > 0 {
last = r.Licenses[l-1]
}
switch {
case l == 0:
case rl.LicenseClassifications != "" && last.LicenseClassifications != "":
case rl.LicenseFile != "" && last.LicenseFile != "":
case rl.LicenseFileURL != "" && last.LicenseFileURL != "":
case rl.LicenseFileFormat != "" && last.LicenseFileFormat != "":
case rl.LicenseNotes != "" && last.LicenseNotes != "":
default:
last.LicenseClassifications = last.LicenseClassifications + rl.LicenseClassifications
last.LicenseFile = last.LicenseFile + rl.LicenseFile
last.LicenseFileURL = last.LicenseFileURL + rl.LicenseFileURL
last.LicenseFileFormat = last.LicenseFileFormat + rl.LicenseFileFormat
last.LicenseNotes = last.LicenseNotes + rl.LicenseNotes
return
}
r.Licenses = append(r.Licenses, rl)
}
// Parse a single line in a README.fuchsia file.
// Find the colon ':', directives are before that, values are after that.
func parseReadmeLine(line string) (string, string, error) {
colon := strings.Index(line, ":")
if colon < 0 {
return "", line, fmt.Errorf("Failed to find ':' in line '%s'", line)
}
directive := line[:colon]
value := strings.TrimSpace(line[colon+1:])
if _, ok := knownDirectives[directive]; !ok {
return "", line, fmt.Errorf("Unknown directive '%s'", directive)
}
return directive, value, nil
}
// Some directives can span multiple lines (e.g. "Description").
// In this case, keep parsing until we find another directive, or we reach the end of the file.
func parseReadmeMultiLineDirective(s *bufio.Scanner, value string) (string, string, string, bool) {
var err error
var b strings.Builder
var line, directive string
b.WriteString(fmt.Sprintf("%s\n", value))
eof := true
for s.Scan() {
line = s.Text()
directive, value, err = parseReadmeLine(line)
if err == nil {
eof = false
break
} else {
b.WriteString(fmt.Sprintf("%s\n", line))
}
}
return directive, value, b.String(), eof
}
// README.fuchsia files should exist for all projects in the filesystem, and
// they should specify the location and URLs of each license file in the project.
//
// Many times this isn't the case: either the URL is missing, or the license file
// is entirely missing from the README.fuchsia file.
//
// Attempt to generate a URL for the given license file.
func (r *Readme) getLicenseURLForPath(licenseFilePath string) (string, error) {
ctx := context.Background()
gitURL, err := git.GetURL(ctx, r.ProjectRoot)
if err != nil {
return "", fmt.Errorf("Failed to get git URL for project path %s: %w",
r.ProjectRoot, err)
}
url := fmt.Sprintf("%s/+/%s/%s", gitURL, GitRevision, licenseFilePath)
// Projects that are hosted in third_party fuchsia repositories do not
// have the project name in the URL path after the + sign.
//
// TODO: Design a better solution to construct these URLs.
if gitURL != "https://fuchsia.googlesource.com/fuchsia" {
url = fmt.Sprintf("%s/+/%s/%s", gitURL, GitRevision, licenseFilePath)
}
return url, nil
}
func (r *Readme) AddLicense(relPath string, licenseFile *file.File) {
for _, l := range r.Licenses {
if l.LicenseFile == relPath {
return
}
}
newLicense := &ReadmeLicense{
LicenseFile: relPath,
LicenseFileFormat: licenseFile.FileType().String(),
LicenseFileRef: licenseFile,
}
if url, err := r.getLicenseURLForPath(relPath); err != nil {
newLicense.LicenseFileURL = url
}
r.Licenses = append(r.Licenses, newLicense)
}
// Sort the internal fields of the Readme struct, so a readme object can deterministically
// be compared against other readme objects.
func (r *Readme) Sort() {
sort.Slice(r.Licenses[:], func(i, j int) bool {
return r.Licenses[i].LicenseFile < r.Licenses[j].LicenseFile
})
}
func (r *Readme) String() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Name: %s\n", r.Name))
addIfNotEmpty(&sb, "URL", r.URL)
addIfNotEmpty(&sb, "Version", r.Version)
addIfNotEmpty(&sb, "Upstream Git", r.UpstreamGit)
for _, o := range r.OtherFields {
addIfNotEmpty(&sb, o.Directive, o.Value)
}
sb.WriteString("\n")
for _, l := range r.Licenses {
sb.WriteString(l.String())
sb.WriteString("\n\n")
}
for _, o := range r.OtherMultiLineFields {
addIfNotEmptyNewline(&sb, o.Directive, o.Value)
sb.WriteString("\n")
}
addIfNotEmptyNewline(&sb, "Description", r.Description)
sb.WriteString("\n")
addIfNotEmptyNewline(&sb, "Local Modifications", r.LocalModifications)
return strings.TrimSpace(sb.String())
}
func (rl *ReadmeLicense) String() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("License File: %s\n", rl.LicenseFile))
if rl.LicenseFileRef != nil {
addIfNotEmpty(&sb, " -> License File Format", string(rl.LicenseFileRef.FileType()))
addIfNotEmpty(&sb, " -> License Classifications", rl.LicenseFileRef.LicenseType())
addIfNotEmpty(&sb, " -> License Exceptions", rl.LicenseExceptions)
addIfNotEmpty(&sb, " -> License File URL", rl.LicenseFileURL)
addIfNotEmpty(&sb, " -> License Skip Reason", rl.LicenseSkipReason)
addIfNotEmpty(&sb, " -> Notes", rl.LicenseNotes)
}
return strings.TrimSpace(sb.String())
}
func addIfNotEmptyNewline(b *strings.Builder, key, val string) {
if len(val) > 0 {
b.WriteString(fmt.Sprintf("%s:\n%s\n", key, strings.TrimSpace(val)))
}
}
func addIfNotEmpty(b *strings.Builder, key, val string) {
if len(val) > 0 {
b.WriteString(fmt.Sprintf("%s: %s\n", key, strings.TrimSpace(val)))
}
}