1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "encoding/json"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "bnema.dev/ermite/internal/domain"
13)
14
15// LicenseBadge names the root license file and its display identifier.
16type LicenseBadge struct {
17 ID, Href string
18}
19
20var licenseNames = []string{"license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt"}
21
22// repositoryLicense caches both absence and detection by immutable head. The
23// caller has already authorized repository browsing. Failures are not cached.
24// A separate instance of the bounded immutable-result cache coalesces misses.
25func (s *Server) repositoryLicense(ctx context.Context, repo domain.RepoName, head string) *LicenseBadge {
26 key := repo.String() + "\x00" + head
27 body, err := s.licenses.load(ctx, repo.String(), key, func() ([]byte, error) {
28 badge, err := s.detectLicense(ctx, repo, head)
29 if err != nil {
30 return nil, err
31 }
32 return json.Marshal(badge)
33 })
34 if err != nil {
35 log := zerowrap.FromCtx(ctx)
36 log.Warn().Err(err).Str("repo", repo.String()).Msg("read the repository license")
37 return nil
38 }
39 var badge *LicenseBadge
40 if err := json.Unmarshal(body, &badge); err != nil {
41 return nil
42 }
43 return badge
44}
45
46//hexcheck:ignore suspicious-business-logic-in-adapter picks a root license file for display only
47func (s *Server) detectLicense(ctx context.Context, repo domain.RepoName, head string) (*LicenseBadge, error) {
48 tree, err := s.getTree(ctx, repo, head, "", false)
49 if err != nil {
50 return nil, err
51 }
52 for _, name := range licenseNames {
53 for _, entry := range tree.Entries {
54 if (entry.Kind != domain.EntryFile && entry.Kind != domain.EntryExecutable) || !strings.EqualFold(name, entry.Name) {
55 continue
56 }
57 body, binary, err := s.readBlob(ctx, repo, head, entry.Path, 64<<10)
58 if err != nil {
59 return nil, err
60 }
61 id := "Other"
62 if !binary {
63 id = licenseID(string(body))
64 }
65 return &LicenseBadge{ID: id, Href: repoHref(repo.String(), "blob", head, entry.Path)}, nil
66 }
67 }
68 return nil, nil
69}
70
71// licenseID deliberately recognizes titles and characteristic grants, not
72// incidental mentions of other licenses in their full text.
73func licenseID(body string) string {
74 for _, line := range strings.Split(body, "\n") {
75 if i := strings.Index(strings.ToLower(line), "spdx-license-identifier:"); i >= 0 {
76 id := strings.TrimSpace(line[i+len("spdx-license-identifier:"):])
77 id = strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(id, "-->"), "*/"))
78 if id != "" && len(id) <= 160 {
79 return id
80 }
81 }
82 }
83 text := strings.ToLower(strings.Join(strings.Fields(body), " "))
84 title := text[:min(len(text), 600)]
85 switch {
86 case strings.Contains(title, "gnu affero general public license") && strings.Contains(title, "version 3"):
87 return "AGPL-3.0"
88 case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 3"):
89 return "LGPL-3.0"
90 case strings.Contains(title, "gnu lesser general public license") && strings.Contains(title, "version 2.1"):
91 return "LGPL-2.1"
92 case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 3"):
93 return "GPL-3.0"
94 case strings.Contains(title, "gnu general public license") && strings.Contains(title, "version 2"):
95 return "GPL-2.0"
96 case strings.Contains(title, "apache license") && strings.Contains(title, "version 2.0"):
97 return "Apache-2.0"
98 case strings.Contains(title, "mozilla public license") && strings.Contains(title, "2.0"):
99 return "MPL-2.0"
100 case strings.Contains(text, "permission is hereby granted, free of charge") && strings.Contains(text, "the software is provided \"as is\""):
101 if strings.Contains(text, "the above copyright notice and this permission notice shall be included in all copies or substantial portions of the software") {
102 return "MIT"
103 }
104 return "MIT-0"
105 case strings.Contains(text, "all advertising materials"):
106 return "Other"
107 case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "neither the name"):
108 return "BSD-3-Clause"
109 case strings.Contains(text, "redistribution and use in source and binary forms") && strings.Contains(text, "this list of conditions"):
110 return "BSD-2-Clause"
111 case strings.Contains(text, "permission to use, copy, modify, and/or distribute this software for any purpose with or without fee"):
112 return "ISC"
113 }
114 return "Other"
115}