@@ -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.
2729func 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