Skip to content

Commit fd9801a

Browse files
committed
Allow github:ORG// repos to redirect to other repos in the same org
Signed-off-by: Jan Dubois <[email protected]>
1 parent 8944d8b commit fd9801a

File tree

3 files changed

+196
-71
lines changed

3 files changed

+196
-71
lines changed

hack/bats/tests/url-github.bats

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,33 @@ load "../helpers/load"
66
# The jandubois/jandubois GitHub repo has been especially constructed to test
77
# various features of the github URL scheme:
88
#
9-
# * repo defaults to org when not specified
10-
# * filename defaults to .lima.yaml when only a path is specified
11-
# * .yaml default extension
12-
# * .lima.yaml files may be treated as symlinks
13-
# * default branch lookup when not specified
9+
# * repo defaults to org when not specified
10+
# * filename defaults to .lima.yaml when only a path is specified
11+
# * .yaml default extension
12+
# * .lima.yaml files may be treated as symlinks
13+
# * default branch lookup when not specified
14+
# * github:ORG// repos can redirect to another github:ORG URL in the same ORG
1415
#
15-
# The repo files are:
16+
# The jandubois/jandubois repo files are:
1617
#
17-
# ├── .lima.yaml -> templates/demo.yaml
18-
# ├── docs
19-
# │ └── .lima.yaml -> ../templates/demo.yaml
20-
# └── templates
21-
# └── demo.yaml
18+
# ├── .lima.yaml -> templates/demo.yaml
19+
# ├── back
20+
# │ └── .lima.yaml -> github:jandubois//loop/
21+
# ├── docs
22+
# │ └── .lima.yaml -> ../templates/demo.yaml
23+
# ├── invalid
24+
# │ ├── org
25+
# │ │ └── .lima.yaml -> github:lima-vm
26+
# │ └── tag
27+
# │ └── .lima.yaml -> github:jandubois//@v0.0.0
28+
# ├── loop
29+
# │ └── .lima.yaml -> github:jandubois//back/
30+
# ├── redirect
31+
# │ └── .lima.yaml -> github:jandubois/lima/templates/default
32+
# ├── templates
33+
# │ └── demo.yaml "base: template:default"
34+
# └── yaml
35+
# └── .lima.yaml "{}"
2236
#
2337
# Both the `main` branch and the `v0.0.0` tag have this layout.
2438

@@ -52,7 +66,7 @@ URLS=(
5266
)
5367

5468
url() {
55-
run_e "$1" limactl template url "$2"
69+
run_e "$1" limactl --debug template url "$2"
5670
}
5771

5872
test_jandubois_url() {
@@ -71,9 +85,14 @@ for url in "${URLS[@]}"; do
7185
bats_test_function --description "$url" -- test_jandubois_url "$url"
7286
done
7387

74-
@test '.lima.yaml is retained when it is not a symlink' {
75-
url -0 'github:jandubois//test/'
76-
assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.lima.yaml'
88+
@test '.lima.yaml is retained when it exits and is not a symlink' {
89+
url -0 'github:jandubois//yaml/'
90+
assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/yaml/.lima.yaml'
91+
}
92+
93+
@test 'non-existing .lima.yaml returns an error' {
94+
url -1 'github:jandubois//missing/'
95+
assert_fatal 'file "https://raw.githubusercontent.com/jandubois/jandubois/main/missing/.lima.yaml" not found or inaccessible: status 404'
7796
}
7897

7998
@test 'hidden files without an extension get a .yaml extension' {
@@ -88,15 +107,62 @@ done
88107

89108
@test 'github: URLs are EXPERIMENTAL' {
90109
url -0 'github:jandubois'
91-
assert_stderr --regexp 'warning.+GitHub locator .* replaced with .* EXPERIMENTAL'
110+
assert_warning "The github: scheme is still EXPERIMENTAL"
92111
}
93112

94-
@test 'Empty github: url returns an error' {
113+
# Invalid URLs
114+
@test 'empty github: url returns an error' {
95115
url -1 'github:'
96116
assert_fatal 'github: URL must contain at least an ORG, got ""'
97117
}
98118

99-
@test 'Missing org returns an error' {
119+
@test 'missing org returns an error' {
100120
url -1 'github:/jandubois'
101121
assert_fatal 'github: URL must contain at least an ORG, got ""'
102122
}
123+
124+
# github: redirects in github:ORG// repos
125+
@test 'org redirects can point to different repo and may switch the branch name' {
126+
url -0 'github:jandubois//redirect/'
127+
# Note that the default branch in jandubois/jandubois is main, but in jandubois/lima it is master
128+
assert_debug 'Locator "github:jandubois//redirect/" replaced with "github:jandubois/lima/templates/default"'
129+
assert_debug 'Locator "github:jandubois/lima/templates/default" replaced with "https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml"'
130+
assert_output 'https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml'
131+
}
132+
133+
@test 'org redirects propagate an explicit branch/tag to the other repo' {
134+
url -0 'github:jandubois//redirect/@v1.2.1'
135+
assert_debug 'Locator "github:jandubois//redirect/@v1.2.1" replaced with "github:jandubois/lima/templates/[email protected]"'
136+
assert_debug 'Locator "github:jandubois/lima/templates/[email protected]" replaced with "https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml"'
137+
assert_output 'https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml'
138+
}
139+
140+
@test 'org redirects cannot point to another org' {
141+
url -1 'github:jandubois//invalid/org/'
142+
assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
143+
}
144+
145+
@test 'org redirects with branch cannot point to another org' {
146+
url -1 'github:jandubois//invalid/org/@main'
147+
assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
148+
}
149+
150+
@test 'org redirects cannot include a branch or tag' {
151+
url -1 'github:jandubois//invalid/tag/'
152+
assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
153+
}
154+
155+
@test 'org redirects with tag cannot include a branch or tag' {
156+
url -1 'github:jandubois//invalid/tag/@v0.0.0'
157+
assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
158+
}
159+
160+
@test 'org redirects must not create circular redirects' {
161+
url -1 'github:jandubois//loop/'
162+
assert_fatal 'custom locator "github:jandubois//loop/" has a redirect loop'
163+
}
164+
165+
@test 'org redirects with branch must not create circular redirects' {
166+
url -1 'github:jandubois//back/@main'
167+
assert_fatal 'custom locator "github:jandubois//back/@main" has a redirect loop'
168+
}

pkg/limatmpl/github.go

Lines changed: 84 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"strings"
1717
)
1818

19+
const defaultFilename = ".lima.yaml"
20+
1921
// transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL.
2022
// Input format: ORG/REPO[/PATH][@BRANCH]
2123
//
@@ -25,43 +27,37 @@ import (
2527
// If PATH is just a directory (trailing slash), it will be set to .lima.yaml
2628
// IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target.
2729
func transformGitHubURL(ctx context.Context, input string) (string, error) {
28-
// Check for explicit branch specification with @ at the end
29-
var branch string
30-
if idx := strings.LastIndex(input, "@"); idx != -1 {
31-
branch = input[idx+1:]
32-
input = input[:idx]
33-
}
30+
input, origBranch, _ := strings.Cut(input, "@")
3431

3532
parts := strings.Split(input, "/")
3633
for len(parts) < 2 {
3734
parts = append(parts, "")
3835
}
39-
4036
org := parts[0]
4137
if org == "" {
4238
return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
4339
}
44-
4540
// If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG
4641
repo := cmp.Or(parts[1], org)
47-
pathPart := strings.Join(parts[2:], "/")
42+
filePath := strings.Join(parts[2:], "/")
4843

49-
if pathPart == "" {
50-
pathPart = ".lima.yaml"
44+
if filePath == "" {
45+
filePath = defaultFilename
5146
} else {
52-
// If path ends with /, it's a directory, so append .lima
53-
if strings.HasSuffix(pathPart, "/") {
54-
pathPart += ".lima"
47+
// If path ends with / then it's a directory, so append .lima
48+
if strings.HasSuffix(filePath, "/") {
49+
filePath += defaultFilename
5550
}
5651

5752
// If the filename (excluding first char for hidden files) has no extension, add .yaml
58-
filename := path.Base(pathPart)
53+
filename := path.Base(filePath)
5954
if !strings.Contains(filename[1:], ".") {
60-
pathPart += ".yaml"
55+
filePath += ".yaml"
6156
}
6257
}
6358

6459
// Query default branch if no branch was specified
60+
branch := origBranch
6561
if branch == "" {
6662
var err error
6763
branch, err = getGitHubDefaultBranch(ctx, org, repo)
@@ -71,13 +67,24 @@ func transformGitHubURL(ctx context.Context, input string) (string, error) {
7167
}
7268

7369
// If filename is .lima.yaml, check if it's a symlink/redirect to another file
74-
if path.Base(pathPart) == ".lima.yaml" {
75-
if redirectPath, err := resolveGitHubSymlink(ctx, org, repo, branch, pathPart); err == nil {
76-
pathPart = redirectPath
77-
}
70+
if path.Base(filePath) == defaultFilename {
71+
return resolveGitHubSymlink(ctx, org, repo, branch, filePath, origBranch)
7872
}
73+
return githubUserContentURL(org, repo, branch, filePath), nil
74+
}
7975

80-
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, pathPart), nil
76+
func githubUserContentURL(org, repo, branch, filePath string) string {
77+
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
78+
}
79+
80+
func getGitHubUserContent(ctx context.Context, org, repo, branch, filePath string) (*http.Response, error) {
81+
url := githubUserContentURL(org, repo, branch, filePath)
82+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to create request: %w", err)
85+
}
86+
req.Header.Set("User-Agent", "lima")
87+
return http.DefaultClient.Do(req)
8188
}
8289

8390
// getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository.
@@ -108,61 +115,96 @@ func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, erro
108115
if err != nil {
109116
return "", fmt.Errorf("failed to read GitHub API response: %w", err)
110117
}
111-
112118
if resp.StatusCode != http.StatusOK {
113119
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
114120
}
115121

116122
var repoData struct {
117123
DefaultBranch string `json:"default_branch"`
118124
}
119-
120125
if err := json.Unmarshal(body, &repoData); err != nil {
121126
return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
122127
}
123-
124128
if repoData.DefaultBranch == "" {
125129
return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
126130
}
127-
128131
return repoData.DefaultBranch, nil
129132
}
130133

131134
// resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file.
132-
// If the file contains a single line without YAML content, it's treated as a path to the actual file.
133-
// Returns the redirect path if found, or the original path otherwise.
134-
func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath string) (string, error) {
135-
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
136-
137-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
138-
if err != nil {
139-
return "", fmt.Errorf("failed to create request: %w", err)
140-
}
141-
142-
req.Header.Set("User-Agent", "lima")
143-
144-
resp, err := http.DefaultClient.Do(req)
135+
// If the file contains a single line without newline, space, or colon then it's treated as a path to the actual file.
136+
// Returns a URL to the redirect path if found, or a URL for original path otherwise.
137+
func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath, origBranch string) (string, error) {
138+
resp, err := getGitHubUserContent(ctx, org, repo, branch, filePath)
145139
if err != nil {
146140
return "", fmt.Errorf("failed to fetch file: %w", err)
147141
}
148142
defer resp.Body.Close()
149143

144+
// Special rule for branch/tag propagation for github:ORG// requests.
145+
if resp.StatusCode == http.StatusNotFound && repo == org {
146+
defaultBranch, err := getGitHubDefaultBranch(ctx, org, repo)
147+
if err == nil {
148+
return resolveGitHubRedirect(ctx, org, repo, defaultBranch, filePath, branch)
149+
}
150+
}
150151
if resp.StatusCode != http.StatusOK {
151-
return "", fmt.Errorf("file not found or inaccessible: status %d", resp.StatusCode)
152+
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
152153
}
153154

154155
// Read first 1KB to check the file content
155156
buf := make([]byte, 1024)
156157
n, err := resp.Body.Read(buf)
157158
if err != nil && !errors.Is(err, io.EOF) {
158-
return "", fmt.Errorf("failed to read file content: %w", err)
159+
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
159160
}
160161
content := string(buf[:n])
161162

163+
// Symlink can also be a github: redirect if we are in a github:ORG// repo.
164+
if repo == org && strings.HasPrefix(content, "github:") {
165+
return validateGitHubRedirect(content, org, origBranch, resp.Request.URL.String())
166+
}
167+
162168
// A symlink must be a single line (without trailing newline), no spaces, no colons
163169
if !(content == "" || strings.ContainsAny(content, "\n :")) {
164170
// symlink is relative to the directory of filePath
165-
return path.Join(path.Dir(filePath), content), nil
171+
filePath = path.Join(path.Dir(filePath), content)
172+
}
173+
return githubUserContentURL(org, repo, branch, filePath), nil
174+
}
175+
176+
// resolveGitHubRedirect checks if a file at the given path is a github: URL to another file within the same repo.
177+
// Returns the URL, or an error if the file doesn't exist, or doesn't start with github:ORG.
178+
func resolveGitHubRedirect(ctx context.Context, org, repo, defaultBranch, filePath, origBranch string) (string, error) {
179+
// Refetch the filepath from the defaultBranch
180+
resp, err := getGitHubUserContent(ctx, org, repo, defaultBranch, filePath)
181+
if err != nil {
182+
return "", fmt.Errorf("failed to fetch file: %w", err)
183+
}
184+
defer resp.Body.Close()
185+
if resp.StatusCode != http.StatusOK {
186+
return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
187+
}
188+
body, err := io.ReadAll(resp.Body)
189+
if err != nil {
190+
return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
191+
}
192+
return validateGitHubRedirect(string(body), org, origBranch, resp.Request.URL.String())
193+
}
194+
195+
func validateGitHubRedirect(body, org, origBranch, url string) (string, error) {
196+
redirect, _, _ := strings.Cut(body, "\n")
197+
redirect = strings.TrimSpace(redirect)
198+
199+
if !strings.HasPrefix(redirect, "github:"+org+"/") {
200+
return "", fmt.Errorf(`redirect %q is not a "github:%s" URL (from %q)`, redirect, org, url)
201+
}
202+
if strings.ContainsRune(redirect, '@') {
203+
return "", fmt.Errorf("redirect %q must not include a branch/tag/sha (from %q)", redirect, url)
204+
}
205+
// If the origBranch is empty, then we need to look up the default branch in the redirect
206+
if origBranch != "" {
207+
redirect += "@" + origBranch
166208
}
167-
return filePath, nil
209+
return redirect, nil
168210
}

0 commit comments

Comments
 (0)