// Package catalog collects notices from the exact packages selected by go list. // It reads root and package-ancestor notice files, including vendored notices // such as gormlite/LICENSE and libc's third-party licence bundle. package catalog import ( "encoding/json" "errors" "io" "os" "path/filepath" "sort" "strings" "unicode/utf8" "amadan.net/oficina/estilo.git/credits" ) type Module struct { Path, Version, Dir string Main bool Replace *Module } type Package struct { Dir string Module *Module Error *struct{ Err string } } type Inventory struct { Components []credits.Component `json:"components"` MissingNotices []string `json:"missing_notices"` } // Collect expects go list -deps -json for one production entry point. Standard // library notices are supplied separately by the command from that Go toolchain. // First-party module names are excluded only by the caller's exact list. func Collect(input io.Reader, exclude map[string]bool) (Inventory, error) { decoder := json.NewDecoder(input) modules := map[string]*Module{} directories := map[string]map[string]bool{} for { var p Package err := decoder.Decode(&p) if errors.Is(err, io.EOF) { break } if err != nil { return Inventory{}, err } if p.Error != nil { return Inventory{}, errors.New("credits: package resolution failed") } m := p.Module if m == nil || m.Main || exclude[m.Path] { continue } if m.Replace != nil { return Inventory{}, errors.New("credits: replaced module requires explicit inventory") } if m.Dir == "" || m.Path == "" || m.Version == "" || p.Dir == "" { return Inventory{}, errors.New("credits: incomplete module provenance") } root := filepath.Clean(m.Dir) rel, err := filepath.Rel(root, p.Dir) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { return Inventory{}, errors.New("credits: package outside module") } if previous := modules[m.Path]; previous != nil && (previous.Version != m.Version || previous.Dir != m.Dir) { return Inventory{}, errors.New("credits: conflicting module versions") } modules[m.Path] = m if directories[m.Path] == nil { directories[m.Path] = map[string]bool{} } for dir := filepath.Clean(p.Dir); ; dir = filepath.Dir(dir) { directories[m.Path][dir] = true if dir == root { break } } } out := Inventory{Components: []credits.Component{}, MissingNotices: []string{}} for name, m := range modules { component := credits.Component{Name: name, Version: m.Version, SourceURL: moduleURL(m), Notices: []credits.Notice{}} for dir := range directories[name] { files, err := os.ReadDir(dir) if err != nil { return Inventory{}, err } for _, entry := range files { if entry.IsDir() || !noticeFilename(entry.Name()) { continue } full := filepath.Join(dir, entry.Name()) info, err := os.Lstat(full) if err != nil { return Inventory{}, err } if !info.Mode().IsRegular() || info.Size() > 2<<20 { return Inventory{}, errors.New("credits: unsupported notice file") } raw, err := os.ReadFile(full) if err != nil { return Inventory{}, err } if !utf8.Valid(raw) || strings.TrimSpace(string(raw)) == "" { return Inventory{}, errors.New("credits: empty or non-UTF-8 notice") } relative, err := filepath.Rel(m.Dir, full) if err != nil { return Inventory{}, err } component.Notices = append(component.Notices, credits.Notice{Name: filepath.ToSlash(relative), Text: string(raw)}) } } sort.Slice(component.Notices, func(i, j int) bool { return component.Notices[i].Name < component.Notices[j].Name }) if len(component.Notices) == 0 { out.MissingNotices = append(out.MissingNotices, name) } out.Components = append(out.Components, component) } sort.Slice(out.Components, func(i, j int) bool { return out.Components[i].Name < out.Components[j].Name }) sort.Strings(out.MissingNotices) return out, nil } func noticeFilename(name string) bool { name = strings.ToUpper(name) if strings.HasSuffix(name, ".GO") { return false } for _, prefix := range []string{"LICENSE", "LICENCE", "COPYING", "NOTICE"} { if name == prefix || strings.HasPrefix(name, prefix+".") || strings.HasPrefix(name, prefix+"-") { return true } } return false } func moduleURL(m *Module) string { if strings.HasPrefix(m.Path, "amadan.net/") { parts := strings.Split(m.Path, "/") if len(parts) >= 3 { return "https://" + parts[0] + "/" + parts[1] + "/" + strings.TrimSuffix(parts[2], ".git") } return "" } return "https://pkg.go.dev/" + m.Path + "@" + m.Version }