1package main
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io/fs"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15
16 "github.com/bnema/go-wayland-bindings/spec"
17)
18
19// candidate is an upstream XML file that may provide a package.
20type candidate struct {
21 source string
22 path string // slash-separated path inside the repository
23 pkg string
24 stability Stability
25 major int
26 data []byte
27}
28
29// tree is the desired content of spec/: the manifest plus the XML files.
30type tree struct {
31 manifest *spec.Manifest
32 xml map[string][]byte // file name under spec/xml
33}
34
35// rank orders candidates of one package: higher is better.
36func (c candidate) beats(o candidate) bool {
37 if c.stability != o.stability {
38 return c.stability > o.stability
39 }
40 return c.major > o.major
41}
42
43func (c candidate) tied(o candidate) bool {
44 return c.stability == o.stability && c.major == o.major
45}
46
47// collect resolves and checks out every source and returns the candidates
48// found, together with the resolved source records.
49func collect(srcs []upstream, pins map[string]string, f Fetcher) ([]spec.Source, []candidate, error) {
50 var (
51 sources []spec.Source
52 cands []candidate
53 )
54 for _, src := range srcs {
55 ref, commit, err := f.Resolve(src, pins[src.ID])
56 if err != nil {
57 return nil, nil, fmt.Errorf("resolve %s: %w", src.ID, err)
58 }
59 found, err := checkoutAndScan(src, ref, commit, f)
60 if err != nil {
61 return nil, nil, err
62 }
63 if len(found) == 0 {
64 return nil, nil, fmt.Errorf("%s@%s: no protocol XML found", src.ID, ref)
65 }
66 sources = append(sources, spec.Source{ID: src.ID, URL: src.URL, Ref: ref, Commit: commit})
67 cands = append(cands, found...)
68 }
69 return sources, cands, nil
70}
71
72func checkoutAndScan(src upstream, ref, commit string, f Fetcher) ([]candidate, error) {
73 dir, err := os.MkdirTemp("", "wlbsync-"+src.ID+"-")
74 if err != nil {
75 return nil, err
76 }
77 defer os.RemoveAll(dir)
78 if err := f.Checkout(src, ref, commit, dir); err != nil {
79 return nil, fmt.Errorf("checkout %s@%s: %w", src.ID, ref, err)
80 }
81 var out []candidate
82 err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
83 if err != nil {
84 return err
85 }
86 if d.IsDir() {
87 if d.Name() == ".git" {
88 return filepath.SkipDir
89 }
90 return nil
91 }
92 if !d.Type().IsRegular() {
93 return nil // symlinks and other special files are never vendored
94 }
95 rel, err := filepath.Rel(dir, p)
96 if err != nil {
97 return err
98 }
99 rel = filepath.ToSlash(rel)
100 st, ok := src.Match(rel)
101 if !ok {
102 return nil
103 }
104 data, err := os.ReadFile(p)
105 if err != nil {
106 return err
107 }
108 out = append(out, candidate{
109 source: src.ID,
110 path: rel,
111 pkg: spec.PackageName(rel),
112 stability: st,
113 major: fileMajor(rel),
114 data: data,
115 })
116 return nil
117 })
118 if err != nil {
119 return nil, fmt.Errorf("scan %s: %w", src.ID, err)
120 }
121 return out, nil
122}
123
124// build applies the selection rule and derives the manifest.
125func build(sources []spec.Source, cands []candidate) (*tree, error) {
126 byPkg := map[string][]candidate{}
127 for _, c := range cands {
128 if !spec.ValidPackageName(c.pkg) {
129 return nil, fmt.Errorf("%s:%s maps to invalid package name %q", c.source, c.path, c.pkg)
130 }
131 byPkg[c.pkg] = append(byPkg[c.pkg], c)
132 }
133
134 t := &tree{manifest: &spec.Manifest{Sources: sources}, xml: map[string][]byte{}}
135 parsed := map[string]*spec.Protocol{}
136 pkgs := make([]string, 0, len(byPkg))
137 for p := range byPkg {
138 pkgs = append(pkgs, p)
139 }
140 sort.Strings(pkgs)
141
142 for _, pkg := range pkgs {
143 cs := byPkg[pkg]
144 sort.Slice(cs, func(i, j int) bool {
145 if cs[i].beats(cs[j]) != cs[j].beats(cs[i]) {
146 return cs[i].beats(cs[j])
147 }
148 return cs[i].source+":"+cs[i].path < cs[j].source+":"+cs[j].path
149 })
150 best := cs[0]
151 if len(cs) > 1 && best.tied(cs[1]) {
152 return nil, fmt.Errorf("package %q: %s:%s and %s:%s tie (same stability and major version)",
153 pkg, best.source, best.path, cs[1].source, cs[1].path)
154 }
155 p, err := spec.Parse(best.data)
156 if err != nil {
157 return nil, fmt.Errorf("%s:%s: %w", best.source, best.path, err)
158 }
159 if strings.TrimSpace(p.Copyright) == "" {
160 return nil, fmt.Errorf("%s:%s: missing <copyright>", best.source, best.path)
161 }
162 parsed[pkg] = p
163
164 sum := sha256.Sum256(best.data)
165 entry := spec.ProtocolEntry{
166 Package: pkg,
167 XML: pkg + ".xml",
168 Source: best.source,
169 Path: best.path,
170 SHA256: hex.EncodeToString(sum[:]),
171 License: licenseFor(pkg),
172 Deps: []string{},
173 }
174 for _, it := range p.Interfaces {
175 entry.Interfaces = append(entry.Interfaces, spec.InterfaceEntry{Name: it.Name, Version: it.Version})
176 }
177 sort.Slice(entry.Interfaces, func(i, j int) bool { return entry.Interfaces[i].Name < entry.Interfaces[j].Name })
178 t.manifest.Protocols = append(t.manifest.Protocols, entry)
179 t.xml[entry.XML] = best.data
180 }
181
182 owners := map[string]string{}
183 for _, e := range t.manifest.Protocols {
184 for _, it := range e.Interfaces {
185 if prev, dup := owners[it.Name]; dup {
186 return nil, fmt.Errorf("interface %q is defined by both %q and %q", it.Name, prev, e.Package)
187 }
188 owners[it.Name] = e.Package
189 }
190 }
191 for i := range t.manifest.Protocols {
192 e := &t.manifest.Protocols[i]
193 deps := map[string]bool{}
194 for _, ref := range parsed[e.Package].ReferencedInterfaces() {
195 owner, ok := owners[ref]
196 if !ok {
197 return nil, fmt.Errorf("package %q references unknown interface %q", e.Package, ref)
198 }
199 if owner != e.Package {
200 deps[owner] = true
201 }
202 }
203 for d := range deps {
204 e.Deps = append(e.Deps, d)
205 }
206 sort.Strings(e.Deps)
207 }
208 t.manifest.Sort()
209 return t, nil
210}
211
212func licenseFor(pkg string) string {
213 if pkg == "serverdecoration" {
214 return "LGPL-2.1-or-later"
215 }
216 return "MIT"
217}
218
219// state is what is currently on disk under spec/.
220type state struct {
221 manifest *spec.Manifest
222 raw []byte
223 xml map[string][]byte
224}
225
226func readState(root string) (*state, error) {
227 s := &state{manifest: &spec.Manifest{}, xml: map[string][]byte{}}
228 raw, err := os.ReadFile(filepath.Join(root, "spec", "manifest.json"))
229 switch {
230 case errors.Is(err, fs.ErrNotExist):
231 case err != nil:
232 return nil, err
233 default:
234 s.raw = raw
235 if len(bytes.TrimSpace(raw)) > 0 {
236 if err := json.Unmarshal(raw, s.manifest); err != nil {
237 return nil, fmt.Errorf("decode current manifest: %w", err)
238 }
239 }
240 }
241 entries, err := os.ReadDir(filepath.Join(root, "spec", "xml"))
242 if err != nil && !errors.Is(err, fs.ErrNotExist) {
243 return nil, err
244 }
245 for _, e := range entries {
246 if e.IsDir() {
247 continue
248 }
249 b, err := os.ReadFile(filepath.Join(root, "spec", "xml", e.Name()))
250 if err != nil {
251 return nil, err
252 }
253 s.xml[e.Name()] = b
254 }
255 return s, nil
256}
257
258// differs reports whether t carries a real content change relative to disk:
259// different XML bytes or different protocol entries. A move of a source ref
260// or commit that leaves every selected XML byte-identical (and the set of
261// packages unchanged) is not a change, so nothing is rewritten and no release
262// is cut for it.
263func (s *state) differs(t *tree) (bool, error) {
264 if len(s.xml) != len(t.xml) {
265 return true, nil
266 }
267 // A different set of upstream repositories is a change.
268 if len(s.manifest.Sources) != len(t.manifest.Sources) {
269 return true, nil
270 }
271 for i, o := range s.manifest.Sources {
272 if n := t.manifest.Sources[i]; o.ID != n.ID || o.URL != n.URL {
273 return true, nil
274 }
275 }
276 // Compare manifests with the current source records substituted in.
277 probe := &spec.Manifest{Sources: s.manifest.Sources, Protocols: t.manifest.Protocols}
278 b, err := probe.Marshal()
279 if err != nil {
280 return false, err
281 }
282 if !bytes.Equal(s.raw, b) {
283 return true, nil
284 }
285 for name, b := range t.xml {
286 if old, ok := s.xml[name]; !ok || !bytes.Equal(old, b) {
287 return true, nil
288 }
289 }
290 return false, nil
291}
292
293// write stores t under root/spec and removes stale XML files.
294func write(root string, s *state, t *tree, manifest []byte) error {
295 dir := filepath.Join(root, "spec", "xml")
296 if err := os.MkdirAll(dir, 0o755); err != nil {
297 return err
298 }
299 for name, b := range t.xml {
300 if old, ok := s.xml[name]; ok && bytes.Equal(old, b) {
301 continue
302 }
303 if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
304 return err
305 }
306 }
307 for name := range s.xml {
308 if _, keep := t.xml[name]; !keep {
309 if err := os.Remove(filepath.Join(dir, name)); err != nil {
310 return err
311 }
312 }
313 }
314 if !bytes.Equal(s.raw, manifest) {
315 return os.WriteFile(filepath.Join(root, "spec", "manifest.json"), manifest, 0o644)
316 }
317 return nil
318}