repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

14.5 KB · 467 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/http"
  7	"regexp"
  8	"sort"
  9	"strings"
 10	"sync"
 11	"time"
 12)
 13
 14// A model writing code from search results invents package names, and it is the
 15// one mistake in a code answer that a reader cannot see. A syntax error shows
 16// up the moment they run it. `pip install flask-yfinance-cache` fails at a point
 17// where they have already decided the answer was good.
 18//
 19// So every package the answer imports is looked up in the registry that would
 20// have to have it, the same way an entity link is fetched and checked to be the
 21// thing it claims. Four registries, all keyless.
 22
 23// Dependency is one package named by the code, and whether it exists.
 24type Dependency struct {
 25	Name string
 26	Eco  string
 27	URL  string
 28	// Found is only meaningful when Checked is true. A registry that did not
 29	// answer proves nothing, and saying so is better than guessing either way.
 30	Found   bool
 31	Checked bool
 32}
 33
 34const (
 35	maxDepChecks = 14
 36	depTimeout   = 6 * time.Second
 37)
 38
 39// verifyDeps looks up every package the blocks import. It is safe to call with
 40// no network: an unreachable registry leaves Checked false.
 41func verifyDeps(ctx context.Context, client *http.Client, blocks []CodeBlock) []Dependency {
 42	found := map[string]Dependency{}
 43	for _, b := range blocks {
 44		for _, d := range depsIn(b) {
 45			key := d.Eco + " " + d.Name
 46			if _, seen := found[key]; !seen {
 47				found[key] = d
 48			}
 49		}
 50	}
 51	if len(found) == 0 {
 52		return nil
 53	}
 54
 55	list := make([]Dependency, 0, len(found))
 56	for _, d := range found {
 57		list = append(list, d)
 58	}
 59	sort.Slice(list, func(a, b int) bool {
 60		if list[a].Eco != list[b].Eco {
 61			return list[a].Eco < list[b].Eco
 62		}
 63		return list[a].Name < list[b].Name
 64	})
 65	if len(list) > maxDepChecks {
 66		list = list[:maxDepChecks]
 67	}
 68
 69	ctx, cancel := context.WithTimeout(ctx, depTimeout*2)
 70	defer cancel()
 71
 72	var wg sync.WaitGroup
 73	sema := make(chan struct{}, 4)
 74	for i := range list {
 75		wg.Add(1)
 76		go func(d *Dependency) {
 77			defer wg.Done()
 78			sema <- struct{}{}
 79			defer func() { <-sema }()
 80			d.Found, d.Checked = registryHas(ctx, client, *d)
 81		}(&list[i])
 82	}
 83	wg.Wait()
 84	return list
 85}
 86
 87// depsIn reads the packages out of one block. Shell blocks are read too, since
 88// an install line names the package exactly and an import only names the module
 89// it happens to expose.
 90func depsIn(b CodeBlock) []Dependency {
 91	switch normalLang(b.Lang, b.File) {
 92	case "python":
 93		return pythonDeps(b.Code)
 94	case "javascript":
 95		return jsDeps(b.Code)
 96	case "go":
 97		return goDeps(b.Code)
 98	case "dockerfile":
 99		return dockerDeps(b.Code)
100	}
101	switch b.Lang {
102	case "sh", "bash", "shell", "console", "zsh", "":
103		return shellDeps(b.Code)
104	}
105	return nil
106}
107
108var (
109	pyImport   = regexp.MustCompile(`(?m)^\s*(?:from\s+([A-Za-z_][\w.]*)|import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*))`)
110	jsImport   = regexp.MustCompile(`(?:from\s+|require\(\s*|import\(\s*)["']([^"'\n]+)["']`)
111	goImport   = regexp.MustCompile(`(?m)^\s*(?:_\s+|\w+\s+)?"([a-z0-9.\-]+\.[a-z]{2,}/[^"\n]+)"`)
112	dockerFrom = regexp.MustCompile(`(?im)^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)`)
113	pipInstall = regexp.MustCompile(`(?i)\bpip3?\s+install\s+([^\n|&;]+)`)
114	npmInstall = regexp.MustCompile(`(?i)\bnpm\s+(?:install|i|add)\s+([^\n|&;]+)|\b(?:bun|yarn|pnpm)\s+add\s+([^\n|&;]+)`)
115	dockerRun  = regexp.MustCompile(`(?im)\bdocker\s+(?:run|pull|create)\s+(.+)$`)
116)
117
118func pythonDeps(code string) []Dependency {
119	var out []Dependency
120	for _, m := range pyImport.FindAllStringSubmatch(code, -1) {
121		names := []string{m[1]}
122		if m[2] != "" {
123			names = strings.Split(m[2], ",")
124		}
125		for _, n := range names {
126			mod := strings.TrimSpace(n)
127			if i := strings.Index(mod, "."); i > 0 {
128				mod = mod[:i]
129			}
130			if mod == "" || pyStdlib[mod] {
131				continue
132			}
133			out = append(out, Dependency{Name: pyDistribution(mod), Eco: "pypi"})
134		}
135	}
136	return out
137}
138
139// pyDistribution maps a module name to the package that installs it, for the
140// handful where they differ. Everything else is imported by its own name.
141func pyDistribution(mod string) string {
142	switch mod {
143	case "bs4":
144		return "beautifulsoup4"
145	case "yaml":
146		return "pyyaml"
147	case "cv2":
148		return "opencv-python"
149	case "PIL":
150		return "pillow"
151	case "dateutil":
152		return "python-dateutil"
153	case "sklearn":
154		return "scikit-learn"
155	case "dotenv":
156		return "python-dotenv"
157	case "serial":
158		return "pyserial"
159	case "OpenSSL":
160		return "pyopenssl"
161	case "jwt":
162		return "pyjwt"
163	case "psycopg2":
164		return "psycopg2-binary"
165	case "flask_cors":
166		return "flask-cors"
167	case "flask_caching":
168		return "flask-caching"
169	}
170	return strings.ReplaceAll(mod, "_", "-")
171}
172
173func jsDeps(code string) []Dependency {
174	var out []Dependency
175	for _, m := range jsImport.FindAllStringSubmatch(code, -1) {
176		spec := m[1]
177		if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") || strings.HasPrefix(spec, "http") {
178			continue
179		}
180		spec = strings.TrimPrefix(spec, "node:")
181		parts := strings.Split(spec, "/")
182		name := parts[0]
183		if strings.HasPrefix(spec, "@") && len(parts) > 1 {
184			name = parts[0] + "/" + parts[1]
185		}
186		if nodeBuiltin[name] {
187			continue
188		}
189		out = append(out, Dependency{Name: name, Eco: "npm"})
190	}
191	return out
192}
193
194func goDeps(code string) []Dependency {
195	var out []Dependency
196	for _, m := range goImport.FindAllStringSubmatch(code, -1) {
197		if mod := goModuleRoot(m[1]); mod != "" {
198			out = append(out, Dependency{Name: mod, Eco: "go"})
199		}
200	}
201	return out
202}
203
204// goModuleRoot trims an import path back to the module the proxy knows about,
205// which for the three big forges is the first three elements.
206func goModuleRoot(path string) string {
207	parts := strings.Split(path, "/")
208	switch parts[0] {
209	case "github.com", "gitlab.com", "bitbucket.org", "codeberg.org":
210		if len(parts) < 3 {
211			return ""
212		}
213		return strings.Join(parts[:3], "/")
214	}
215	return path
216}
217
218func dockerDeps(code string) []Dependency {
219	var out []Dependency
220	for _, m := range dockerFrom.FindAllStringSubmatch(code, -1) {
221		if img := dockerRepo(m[1]); img != "" {
222			out = append(out, Dependency{Name: img, Eco: "docker"})
223		}
224	}
225	return out
226}
227
228func shellDeps(code string) []Dependency {
229	var out []Dependency
230	for _, m := range pipInstall.FindAllStringSubmatch(code, -1) {
231		for _, name := range installArgs(m[1]) {
232			out = append(out, Dependency{Name: name, Eco: "pypi"})
233		}
234	}
235	for _, m := range npmInstall.FindAllStringSubmatch(code, -1) {
236		for _, name := range installArgs(m[1] + m[2]) {
237			if !nodeBuiltin[name] {
238				out = append(out, Dependency{Name: name, Eco: "npm"})
239			}
240		}
241	}
242	for _, m := range dockerRun.FindAllStringSubmatch(code, -1) {
243		if img := dockerRepo(imageArg(m[1])); img != "" {
244			out = append(out, Dependency{Name: img, Eco: "docker"})
245		}
246	}
247	return out
248}
249
250// installArgs takes the package names off an install line, dropping flags and
251// version pins.
252func installArgs(s string) []string {
253	var out []string
254	for _, f := range strings.Fields(s) {
255		if strings.HasPrefix(f, "-") {
256			continue
257		}
258		f = strings.Trim(f, `"'`)
259		// A pin, an extra or a path is not a name the registry answers to.
260		if i := strings.IndexAny(f, "=<>[!~@"); i > 0 {
261			f = f[:i]
262		}
263		if f == "" || strings.ContainsAny(f, "/.$") && !strings.HasPrefix(f, "@") {
264			continue
265		}
266		out = append(out, strings.ToLower(f))
267	}
268	return out
269}
270
271// takesValue is the docker run flags whose value is a separate word, so the
272// token after one of them is not the image.
273var takesValue = map[string]bool{
274	"-p": true, "-v": true, "-e": true, "-u": true, "-w": true, "-h": true,
275	"--name": true, "--network": true, "--net": true, "--gpus": true,
276	"--restart": true, "--entrypoint": true, "--add-host": true, "--env": true,
277	"--volume": true, "--publish": true, "--user": true, "--workdir": true,
278	"--label": true, "--mount": true, "--device": true, "--memory": true,
279}
280
281// imageArg is the first word of a docker run line that is not a flag or a
282// flag's value. Matching the image with a regex means matching docker's whole
283// flag grammar, and it gets the container name instead about half the time.
284func imageArg(rest string) string {
285	fields := strings.Fields(rest)
286	for i := 0; i < len(fields); i++ {
287		f := fields[i]
288		if !strings.HasPrefix(f, "-") {
289			return f
290		}
291		if takesValue[f] {
292			i++
293		}
294	}
295	return ""
296}
297
298// dockerRepo normalises an image reference to what Docker Hub's API wants, and
299// drops anything hosted somewhere else since only Hub is being asked.
300func dockerRepo(ref string) string {
301	ref = strings.TrimSpace(ref)
302	if ref == "" || ref == "scratch" || strings.HasPrefix(ref, "$") {
303		return ""
304	}
305	// A build stage name, not an image.
306	if !strings.ContainsAny(ref, ":/") && !knownBareImage[ref] {
307		return ""
308	}
309	if i := strings.Index(ref, "@"); i > 0 {
310		ref = ref[:i]
311	}
312	name := ref
313	if i := strings.LastIndex(name, ":"); i > 0 && !strings.Contains(name[i:], "/") {
314		name = name[:i]
315	}
316	parts := strings.Split(name, "/")
317	switch len(parts) {
318	case 1:
319		return "library/" + parts[0]
320	case 2:
321		// A registry host in front means it is not on Hub.
322		if strings.Contains(parts[0], ".") {
323			return ""
324		}
325		return name
326	}
327	return ""
328}
329
330// knownBareImage is the small set of official images written with no tag, where
331// a bare word really is an image rather than a build stage.
332var knownBareImage = map[string]bool{
333	"alpine": true, "debian": true, "ubuntu": true, "python": true, "node": true,
334	"golang": true, "nginx": true, "redis": true, "postgres": true, "mysql": true,
335	"busybox": true, "ollama/ollama": true,
336}
337
338func registryHas(ctx context.Context, client *http.Client, d Dependency) (found, checked bool) {
339	var url string
340	switch d.Eco {
341	case "pypi":
342		url = "https://pypi.org/pypi/" + d.Name + "/json"
343	case "npm":
344		url = "https://registry.npmjs.org/" + d.Name
345	case "go":
346		url = "https://proxy.golang.org/" + goProxyEscape(d.Name) + "/@v/list"
347	case "docker":
348		url = "https://hub.docker.com/v2/repositories/" + d.Name + "/"
349	default:
350		return false, false
351	}
352
353	ctx, cancel := context.WithTimeout(ctx, depTimeout)
354	defer cancel()
355	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
356	if err != nil {
357		return false, false
358	}
359	req.Header.Set("User-Agent", browserUA)
360	resp, err := client.Do(req)
361	if err != nil {
362		return false, false
363	}
364	defer resp.Body.Close()
365	switch {
366	case resp.StatusCode == http.StatusOK:
367		return true, true
368	case resp.StatusCode == http.StatusNotFound:
369		return false, true
370	}
371	// A 403 or a 429 says the registry is not answering questions right now,
372	// which is not evidence the package is missing.
373	return false, false
374}
375
376// goProxyEscape is the proxy's own casing rule: an upper case letter becomes
377// !lower, since the protocol is case insensitive on disk.
378func goProxyEscape(path string) string {
379	var b strings.Builder
380	for _, r := range path {
381		if r >= 'A' && r <= 'Z' {
382			b.WriteByte('!')
383			b.WriteRune(r + 32)
384			continue
385		}
386		b.WriteRune(r)
387	}
388	return b.String()
389}
390
391// depWarnings names anything the registry says does not exist. A package that
392// could not be checked is left silent, since a warning nobody can act on is
393// worse than none.
394func depWarnings(deps []Dependency) []string {
395	var missing []string
396	for _, d := range deps {
397		if d.Checked && !d.Found {
398			missing = append(missing, fmt.Sprintf("%s (%s)", d.Name, ecoName(d.Eco)))
399		}
400	}
401	if len(missing) == 0 {
402		return nil
403	}
404	return []string{fmt.Sprintf(
405		"the code uses %s, which %s not there when checked, so the name is probably wrong",
406		strings.Join(missing, ", "),
407		map[bool]string{true: "was", false: "were"}[len(missing) == 1])}
408}
409
410func ecoName(eco string) string {
411	switch eco {
412	case "pypi":
413		return "PyPI"
414	case "npm":
415		return "npm"
416	case "go":
417		return "Go modules"
418	case "docker":
419		return "Docker Hub"
420	}
421	return eco
422}
423
424// nodeBuiltin is what node ships with, so an import of it is not a package.
425var nodeBuiltin = map[string]bool{
426	"assert": true, "buffer": true, "child_process": true, "cluster": true,
427	"console": true, "crypto": true, "dgram": true, "dns": true, "events": true,
428	"fs": true, "http": true, "http2": true, "https": true, "net": true,
429	"os": true, "path": true, "process": true, "querystring": true,
430	"readline": true, "stream": true, "string_decoder": true, "timers": true,
431	"tls": true, "tty": true, "url": true, "util": true, "v8": true, "vm": true,
432	"worker_threads": true, "zlib": true, "perf_hooks": true, "test": true,
433}
434
435// pyStdlib is the standard library as of 3.13, near enough. A name missing from
436// here costs one lookup that comes back found, which is the harmless direction.
437var pyStdlib = map[string]bool{
438	"abc": true, "argparse": true, "array": true, "ast": true, "asyncio": true,
439	"base64": true, "binascii": true, "bisect": true, "builtins": true, "bz2": true,
440	"calendar": true, "cmath": true, "cmd": true, "collections": true, "colorsys": true,
441	"concurrent": true, "configparser": true, "contextlib": true, "copy": true,
442	"csv": true, "ctypes": true, "dataclasses": true, "datetime": true, "decimal": true,
443	"difflib": true, "dis": true, "email": true, "enum": true, "errno": true,
444	"faulthandler": true, "filecmp": true, "fileinput": true, "fnmatch": true,
445	"fractions": true, "ftplib": true, "functools": true, "gc": true, "getopt": true,
446	"getpass": true, "gettext": true, "glob": true, "gzip": true, "hashlib": true,
447	"heapq": true, "hmac": true, "html": true, "http": true, "imaplib": true,
448	"importlib": true, "inspect": true, "io": true, "ipaddress": true, "itertools": true,
449	"json": true, "keyword": true, "linecache": true, "locale": true, "logging": true,
450	"lzma": true, "mailbox": true, "math": true, "mimetypes": true, "mmap": true,
451	"multiprocessing": true, "netrc": true, "numbers": true, "operator": true,
452	"os": true, "pathlib": true, "pickle": true, "pkgutil": true, "platform": true,
453	"plistlib": true, "poplib": true, "pprint": true, "profile": true, "pty": true,
454	"queue": true, "quopri": true, "random": true, "re": true, "readline": true,
455	"reprlib": true, "resource": true, "runpy": true, "sched": true, "secrets": true,
456	"select": true, "selectors": true, "shelve": true, "shlex": true, "shutil": true,
457	"signal": true, "site": true, "smtplib": true, "socket": true, "socketserver": true,
458	"sqlite3": true, "ssl": true, "stat": true, "statistics": true, "string": true,
459	"struct": true, "subprocess": true, "sys": true, "sysconfig": true, "tarfile": true,
460	"tempfile": true, "textwrap": true, "threading": true, "time": true, "timeit": true,
461	"tkinter": true, "token": true, "tokenize": true, "tomllib": true, "trace": true,
462	"traceback": true, "tracemalloc": true, "types": true, "typing": true,
463	"unicodedata": true, "unittest": true, "urllib": true, "uuid": true, "venv": true,
464	"warnings": true, "wave": true, "weakref": true, "webbrowser": true, "wsgiref": true,
465	"xml": true, "xmlrpc": true, "zipfile": true, "zipimport": true, "zoneinfo": true,
466}