Skip to content

Commit 8e27494

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 1bc3554 commit 8e27494

3 files changed

Lines changed: 715 additions & 7 deletions

File tree

internal/cli/compatibility.go

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

0 commit comments

Comments
 (0)