@@ -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