Skip to content

Commit eb597d7

Browse files
balanzaclaude
andcommitted
feat(create): implement --compatibility to fetch supported image list
Ports show_compatibility from bash distrobox-create. Downloads docs/compatibility.md from upstream and caches the parsed list in the user cache dir. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d02374e commit eb597d7

3 files changed

Lines changed: 856 additions & 7 deletions

File tree

internal/cli/compatibility.go

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"regexp"
12+
"sort"
13+
"strings"
14+
"time"
15+
"unicode"
16+
17+
"github.com/89luca89/distrobox/pkg/version"
18+
)
19+
20+
const (
21+
// compatibilityURLTemplate is the upstream URL for docs/compatibility.md.
22+
// %s is the git ref (tag or branch) to fetch from.
23+
compatibilityURLTemplate = "https://raw.githubusercontent.com/89luca89/distrobox/%s/docs/compatibility.md"
24+
25+
// compatibilityFetchTimeout caps the time we wait for the HTTP request.
26+
compatibilityFetchTimeout = 15 * time.Second
27+
28+
// devFallbackRef is used in place of the "dev" version (set on local
29+
// builds) to fetch the latest upstream compatibility list.
30+
devFallbackRef = "main"
31+
32+
// containersDistrosHeader is the markdown heading that precedes the
33+
// "Containers Distros" table in docs/compatibility.md. We parse the
34+
// first markdown table that follows it; everything else (including
35+
// the "Host Distros" table above it) is ignored.
36+
containersDistrosHeader = "## Containers Distros"
37+
)
38+
39+
// showCompatibility prints the list of container images known to work with
40+
// distrobox. The list is fetched from the upstream docs/compatibility.md (for
41+
// the current version) and cached on disk so subsequent invocations are
42+
// offline-friendly. The provided context is used to cancel the upstream HTTP
43+
// fetch (e.g., via SIGINT); local cache reads/writes are intentionally
44+
// uncancellable since they are bounded and very fast.
45+
//
46+
// This is the Go port of the bash show_compatibility helper:
47+
// https://github.com/89luca89/distrobox/blob/main/distrobox-create#L254
48+
func showCompatibility(ctx context.Context) error {
49+
ref := compatibilityRef(version.Version)
50+
51+
cacheDir, err := compatibilityCacheDir()
52+
if err != nil {
53+
return fmt.Errorf("resolve cache directory: %w", err)
54+
}
55+
// Use the sanitized ref in the cache filename so that an unusual
56+
// build-time version string (e.g. one containing "/" or "..") can
57+
// never escape the cache directory.
58+
cachePath := filepath.Join(cacheDir, "distrobox-compatibility-"+sanitizeRefForFilename(ref))
59+
60+
content, err := readCompatibilityCache(cachePath)
61+
if err == nil {
62+
fmt.Print(content) //nolint:forbidigo // CLI output by design
63+
return nil
64+
}
65+
if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errEmptyCache) {
66+
return fmt.Errorf("read compatibility cache: %w", err)
67+
}
68+
69+
fetchCtx, cancel := context.WithTimeout(ctx, compatibilityFetchTimeout)
70+
defer cancel()
71+
72+
markdown, err := fetchCompatibilityMarkdown(fetchCtx, http.DefaultClient, ref)
73+
if err != nil {
74+
return fmt.Errorf("fetch compatibility list: %w", err)
75+
}
76+
77+
images := parseCompatibilityImages(markdown)
78+
if len(images) == 0 {
79+
return errors.New("parse compatibility list: no images found")
80+
}
81+
82+
rendered := strings.Join(images, "\n") + "\n"
83+
84+
if err := writeCompatibilityCache(cachePath, rendered); err != nil {
85+
// Cache write failure is not fatal: we still got the data and
86+
// the user is entitled to see it.
87+
fmt.Fprintf(os.Stderr, "warning: could not write compatibility cache to %s: %v\n", cachePath, err)
88+
}
89+
90+
fmt.Print(rendered) //nolint:forbidigo // CLI output by design
91+
return nil
92+
}
93+
94+
// gitDescribeSuffixRE matches the trailing `-N-gHEX` segment that
95+
// `git describe --tags --always` appends to a tag when HEAD is N commits
96+
// past the tag (e.g. `v1.8.1-3-g1bc3554`). Stripping it gives us the tag
97+
// itself, which is a valid GitHub ref upstream.
98+
var gitDescribeSuffixRE = regexp.MustCompile(`-\d+-g[0-9a-f]{4,40}$`)
99+
100+
// compatibilityRef returns the git ref to fetch docs/compatibility.md from.
101+
//
102+
// - "" and "dev" (local builds without ldflags overrides) fall back to "main".
103+
// - A `git describe` output like "v1.8.1-3-g1bc3554" or
104+
// "v1.8.1-3-g1bc3554-dirty" is normalized to the nearest tag ("v1.8.1"),
105+
// which is a real ref upstream.
106+
// - A bare commit-only output like "g1bc3554" (no tag prefix) also falls
107+
// back to "main".
108+
// - Anything else is used as-is.
109+
func compatibilityRef(buildVersion string) string {
110+
if buildVersion == "" || buildVersion == "dev" {
111+
return devFallbackRef
112+
}
113+
114+
// `git describe --dirty` appends "-dirty"; drop it first so the
115+
// suffix regex sees the canonical `-N-gHEX` shape.
116+
ref := strings.TrimSuffix(buildVersion, "-dirty")
117+
ref = gitDescribeSuffixRE.ReplaceAllString(ref, "")
118+
119+
// `git describe --always` falls back to a bare hash for untagged
120+
// repositories. That is not a useful ref for upstream, so fall back
121+
// to main.
122+
if isGitHashOnly(ref) {
123+
return devFallbackRef
124+
}
125+
126+
return ref
127+
}
128+
129+
// sanitizeRefForFilename returns a version of ref safe to embed in a
130+
// filename: every character that is not [A-Za-z0-9._-] is replaced with
131+
// `_`. This prevents a ref containing path separators (e.g.
132+
// "feature/foo") or dot-segments (e.g. "../etc/passwd") from creating
133+
// nested paths or escaping the cache directory when concatenated into
134+
// the cache file name.
135+
func sanitizeRefForFilename(ref string) string {
136+
if ref == "" {
137+
return "_"
138+
}
139+
mapped := strings.Map(func(r rune) rune {
140+
switch {
141+
case r >= 'a' && r <= 'z',
142+
r >= 'A' && r <= 'Z',
143+
r >= '0' && r <= '9',
144+
r == '.', r == '_', r == '-':
145+
return r
146+
default:
147+
return '_'
148+
}
149+
}, ref)
150+
// A ref of "." or ".." would still be problematic after the per-char
151+
// mapping (it stays as-is), so explicitly neutralize it.
152+
if mapped == "." || mapped == ".." {
153+
return "_"
154+
}
155+
return mapped
156+
}
157+
158+
// isGitHashOnly reports whether ref looks like a bare git hash with no
159+
// surrounding tag information (matches the "g<hex>" or plain "<hex>"
160+
// shapes that `git describe --always` produces when no tag is reachable).
161+
func isGitHashOnly(ref string) bool {
162+
candidate := strings.TrimPrefix(ref, "g")
163+
if len(candidate) < 4 {
164+
return false
165+
}
166+
for _, r := range candidate {
167+
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
168+
return false
169+
}
170+
}
171+
return true
172+
}
173+
174+
// compatibilityCacheDir returns the per-user cache directory used to store
175+
// the parsed compatibility list. It honours XDG_CACHE_HOME and falls back to
176+
// $HOME/.cache.
177+
func compatibilityCacheDir() (string, error) {
178+
base := os.Getenv("XDG_CACHE_HOME")
179+
if base == "" {
180+
home, err := os.UserHomeDir()
181+
if err != nil {
182+
return "", fmt.Errorf("user home directory: %w", err)
183+
}
184+
base = filepath.Join(home, ".cache")
185+
}
186+
return filepath.Join(base, "distrobox"), nil
187+
}
188+
189+
// errEmptyCache is returned by readCompatibilityCache when the cache file
190+
// exists but contains no data. The caller treats this the same as a miss.
191+
var errEmptyCache = errors.New("compatibility cache file is empty")
192+
193+
// readCompatibilityCache returns the cached compatibility list if a non-empty
194+
// cache file exists. It returns os.ErrNotExist when the cache is absent and
195+
// errEmptyCache when the file is present but empty.
196+
func readCompatibilityCache(path string) (string, error) {
197+
info, err := os.Stat(path)
198+
if err != nil {
199+
return "", err //nolint:wrapcheck // sentinel errors are propagated as-is for the caller
200+
}
201+
if info.Size() == 0 {
202+
return "", errEmptyCache
203+
}
204+
data, err := os.ReadFile(path)
205+
if err != nil {
206+
return "", fmt.Errorf("read cache file: %w", err)
207+
}
208+
return string(data), nil
209+
}
210+
211+
// writeCompatibilityCache atomically writes the parsed compatibility list
212+
// to the on-disk cache, creating the parent directory if needed. The write
213+
// goes to a temp file in the same directory and is then renamed into place
214+
// so that a crashed or concurrent writer can never leave a partially
215+
// populated cache file visible to readCompatibilityCache.
216+
func writeCompatibilityCache(path, content string) error {
217+
dir := filepath.Dir(path)
218+
if err := os.MkdirAll(dir, 0o750); err != nil {
219+
return fmt.Errorf("create cache directory: %w", err)
220+
}
221+
222+
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
223+
if err != nil {
224+
return fmt.Errorf("create temp cache file: %w", err)
225+
}
226+
tmpPath := tmp.Name()
227+
// If anything below fails after the temp file is opened, do our best
228+
// to leave no garbage behind.
229+
cleanup := func() { _ = os.Remove(tmpPath) }
230+
231+
if _, err := tmp.WriteString(content); err != nil {
232+
_ = tmp.Close()
233+
cleanup()
234+
return fmt.Errorf("write temp cache file: %w", err)
235+
}
236+
if err := tmp.Chmod(0o644); err != nil {
237+
_ = tmp.Close()
238+
cleanup()
239+
return fmt.Errorf("chmod temp cache file: %w", err)
240+
}
241+
if err := tmp.Close(); err != nil {
242+
cleanup()
243+
return fmt.Errorf("close temp cache file: %w", err)
244+
}
245+
if err := os.Rename(tmpPath, path); err != nil {
246+
cleanup()
247+
return fmt.Errorf("rename temp cache file into place: %w", err)
248+
}
249+
return nil
250+
}
251+
252+
// fetchCompatibilityMarkdown downloads docs/compatibility.md for the given
253+
// git ref using the provided HTTP client.
254+
func fetchCompatibilityMarkdown(ctx context.Context, client *http.Client, ref string) (string, error) {
255+
url := fmt.Sprintf(compatibilityURLTemplate, ref)
256+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
257+
if err != nil {
258+
return "", fmt.Errorf("build request: %w", err)
259+
}
260+
resp, err := client.Do(req)
261+
if err != nil {
262+
return "", fmt.Errorf("perform request: %w", err)
263+
}
264+
defer resp.Body.Close()
265+
266+
if resp.StatusCode != http.StatusOK {
267+
return "", fmt.Errorf("unexpected HTTP status %d fetching %s", resp.StatusCode, url)
268+
}
269+
270+
body, err := io.ReadAll(resp.Body)
271+
if err != nil {
272+
return "", fmt.Errorf("read response body: %w", err)
273+
}
274+
return string(body), nil
275+
}
276+
277+
// parseCompatibilityImages extracts the container image references from the
278+
// "Containers Distros" table in compatibility.md. It walks the document to
279+
// the `## Containers Distros` heading, locates the first markdown table that
280+
// follows (recognised by its `| --- |`-style separator row), then harvests
281+
// the third column of each subsequent row until the table ends (a non-table,
282+
// non-blank line) or another heading is reached. The header row and the
283+
// separator row are skipped automatically because they appear before
284+
// pastSeparator becomes true. Returned slice is sorted and deduplicated,
285+
// mirroring the `sort -u` behaviour of the original bash implementation.
286+
func parseCompatibilityImages(markdown string) []string {
287+
seen := make(map[string]struct{})
288+
289+
var (
290+
inSection bool
291+
pastSeparator bool
292+
)
293+
for _, line := range strings.Split(markdown, "\n") {
294+
trimmed := strings.TrimSpace(line)
295+
296+
if !inSection {
297+
if trimmed == containersDistrosHeader {
298+
inSection = true
299+
}
300+
continue
301+
}
302+
303+
// A subsequent heading at any level closes our section.
304+
if strings.HasPrefix(trimmed, "#") {
305+
break
306+
}
307+
308+
if !pastSeparator {
309+
if isMarkdownTableSeparator(trimmed) {
310+
pastSeparator = true
311+
}
312+
continue
313+
}
314+
315+
// Past the separator the table ends as soon as we see a non-blank
316+
// line that is not a row. Blank lines and empty-cell rows are
317+
// tolerated and simply yield no images.
318+
if trimmed != "" && !strings.HasPrefix(trimmed, "|") {
319+
break
320+
}
321+
322+
for _, image := range extractImagesFromRow(line) {
323+
seen[image] = struct{}{}
324+
}
325+
}
326+
327+
images := make([]string, 0, len(seen))
328+
for image := range seen {
329+
images = append(images, image)
330+
}
331+
sort.Strings(images)
332+
return images
333+
}
334+
335+
// isMarkdownTableSeparator reports whether line is the separator row of a
336+
// markdown table (e.g., `| --- | --- | --- |`, optionally with alignment
337+
// colons such as `| :--- | :---: | ---: |`). The line is expected to be
338+
// already whitespace-trimmed.
339+
func isMarkdownTableSeparator(line string) bool {
340+
if !strings.HasPrefix(line, "|") || !strings.Contains(line, "---") {
341+
return false
342+
}
343+
for _, r := range line {
344+
switch r {
345+
case '|', '-', ':', ' ', '\t':
346+
continue
347+
default:
348+
return false
349+
}
350+
}
351+
return true
352+
}
353+
354+
// extractImagesFromRow returns the image references from a single markdown
355+
// table row. The original bash pipeline cuts on `|`, takes the fourth field
356+
// (column 3 of the table when counting from 1), splits on `<br>`, strips
357+
// whitespace and drops empty entries.
358+
func extractImagesFromRow(line string) []string {
359+
fields := strings.Split(line, "|")
360+
// `| a | b | c |` splits into ["", " a ", " b ", " c ", ""], so the
361+
// fourth field (index 4 with `cut -f 4`, index 3 here) holds the
362+
// images column. Anything shorter is not a table row we care about.
363+
if len(fields) < 5 {
364+
return nil
365+
}
366+
367+
cell := fields[3]
368+
var images []string
369+
for _, part := range strings.Split(cell, "<br>") {
370+
image := strings.TrimSpace(part)
371+
// Mirror the bash `tr -d ' '` and the later `sort -u`: also drop
372+
// any stray whitespace within the entry (some rows have stray
373+
// spaces inside the image name).
374+
image = strings.Map(stripWhitespace, image)
375+
if image == "" {
376+
continue
377+
}
378+
images = append(images, image)
379+
}
380+
return images
381+
}
382+
383+
// stripWhitespace is a strings.Map helper that drops every whitespace rune.
384+
func stripWhitespace(r rune) rune {
385+
if unicode.IsSpace(r) {
386+
return -1
387+
}
388+
return r
389+
}

0 commit comments

Comments
 (0)