11package github
22
33import (
4+ "bytes"
45 "context"
6+ "crypto/sha1" //nolint:gosec // Git object IDs are defined using SHA-1.
7+ "encoding/hex"
58 "encoding/json"
69 "fmt"
710 "net/http"
811 "net/url"
912 pathpkg "path"
1013 "strings"
14+ "unicode/utf8"
1115
1216 ghErrors "github.com/github/github-mcp-server/pkg/errors"
1317 "github.com/github/github-mcp-server/pkg/raw"
@@ -92,7 +96,45 @@ func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client
9296 return createdRef , nil
9397}
9498
95- const gitSymlinkMode = "120000"
99+ const (
100+ gitSymlinkMode = "120000"
101+ gitSubmoduleMode = "160000"
102+ maxGitTreeTraversalDepth = 64
103+ dereferencedContentLabel = "dereferenced_target"
104+ unavailableSymlinkContents = "not_returned"
105+ )
106+
107+ type repositorySymlink struct {
108+ Path string
109+ SHA string
110+ Target string
111+ ResolvedTargetPath string
112+ Explicit bool
113+ }
114+
115+ type repositoryFileInspection struct {
116+ Content []byte
117+ ContentAvailable bool
118+ Symlink * repositorySymlink
119+ Submodule * repositorySubmoduleReadMetadata
120+ }
121+
122+ type repositorySymlinkReadMetadata struct {
123+ Type string `json:"type"`
124+ Path string `json:"path"`
125+ SHA string `json:"sha,omitempty"`
126+ Target string `json:"target"`
127+ ResolvedTargetPath string `json:"resolved_path,omitempty"`
128+ Content string `json:"content"`
129+ Note string `json:"note,omitempty"`
130+ }
131+
132+ type repositorySubmoduleReadMetadata struct {
133+ Type string `json:"type"`
134+ Path string `json:"path"`
135+ SHA string `json:"sha,omitempty"`
136+ GitURL string `json:"git_url,omitempty"`
137+ }
96138
97139type symlinkWriteBlockedError struct {
98140 Error string `json:"error"`
@@ -129,6 +171,163 @@ func newSymlinkWriteBlockedResult(path, target string) *mcp.CallToolResult {
129171 }
130172}
131173
174+ func inspectRepositoryFile (ctx context.Context , client * github.Client , owner , repo , treeish , path string , file * github.RepositoryContent ) (* repositoryFileInspection , * github.Response , error ) {
175+ if file .GetType () == "symlink" {
176+ content , available , err := suppliedRepositoryContent (file )
177+ if err != nil {
178+ return nil , nil , err
179+ }
180+ return & repositoryFileInspection {
181+ Content : content ,
182+ ContentAvailable : available ,
183+ Symlink : newRepositorySymlink (path , file .GetSHA (), file .GetTarget (), true ),
184+ }, nil , nil
185+ }
186+
187+ if file .GetType () == "submodule" || file .GetSubmoduleGitURL () != "" {
188+ return & repositoryFileInspection {
189+ Submodule : & repositorySubmoduleReadMetadata {
190+ Type : "submodule" ,
191+ Path : path ,
192+ SHA : file .GetSHA (),
193+ GitURL : file .GetSubmoduleGitURL (),
194+ },
195+ }, nil , nil
196+ }
197+
198+ content , available , err := suppliedRepositoryContent (file )
199+ if err != nil {
200+ return nil , nil , err
201+ }
202+ if available {
203+ if ! looksLikeSHA (file .GetSHA ()) {
204+ return nil , nil , fmt .Errorf ("contents API returned malformed Git blob SHA %q for path %q" , file .GetSHA (), path )
205+ }
206+ if strings .EqualFold (gitBlobSHA1 (content ), file .GetSHA ()) {
207+ return & repositoryFileInspection {Content : content , ContentAvailable : true }, nil , nil
208+ }
209+
210+ target , resp , err := symlinkTargetFromBlob (ctx , client , owner , repo , file .GetSHA ())
211+ if err != nil {
212+ return nil , resp , fmt .Errorf ("contents API bytes did not match the reported Git blob and the path blob was not a valid symbolic link target: %w" , err )
213+ }
214+ return & repositoryFileInspection {
215+ Content : content ,
216+ ContentAvailable : true ,
217+ Symlink : newRepositorySymlink (path , file .GetSHA (), target , false ),
218+ }, nil , nil
219+ }
220+
221+ entry , resp , err := getTreeEntry (ctx , client , owner , repo , treeish , path )
222+ if err != nil {
223+ return nil , resp , err
224+ }
225+ if entry == nil {
226+ return nil , nil , fmt .Errorf ("path %q exists according to the Contents API but was not found in the Git tree" , path )
227+ }
228+ if ! looksLikeSHA (file .GetSHA ()) || ! strings .EqualFold (file .GetSHA (), entry .GetSHA ()) {
229+ return nil , nil , fmt .Errorf ("contents API blob SHA %q does not match Git tree blob SHA %q for path %q" , file .GetSHA (), entry .GetSHA (), path )
230+ }
231+
232+ switch entry .GetMode () {
233+ case gitSymlinkMode :
234+ target , resp , err := symlinkTargetFromBlob (ctx , client , owner , repo , entry .GetSHA ())
235+ if err != nil {
236+ return nil , resp , err
237+ }
238+ return & repositoryFileInspection {
239+ Symlink : newRepositorySymlink (path , entry .GetSHA (), target , false ),
240+ }, nil , nil
241+ case gitSubmoduleMode :
242+ return & repositoryFileInspection {
243+ Submodule : & repositorySubmoduleReadMetadata {
244+ Type : "submodule" ,
245+ Path : path ,
246+ SHA : entry .GetSHA (),
247+ },
248+ }, nil , nil
249+ default :
250+ return & repositoryFileInspection {}, nil , nil
251+ }
252+ }
253+
254+ func suppliedRepositoryContent (file * github.RepositoryContent ) ([]byte , bool , error ) {
255+ if file .Content != nil {
256+ content , err := file .GetContent ()
257+ if err != nil {
258+ return nil , false , fmt .Errorf ("failed to decode file content: %w" , err )
259+ }
260+ return []byte (content ), true , nil
261+ }
262+ if file .GetType () != "symlink" && file .GetSize () == 0 {
263+ return []byte {}, true , nil
264+ }
265+ return nil , false , nil
266+ }
267+
268+ func gitBlobSHA1 (content []byte ) string {
269+ hasher := sha1 .New () //nolint:gosec // SHA-1 is required by the Git object ID format.
270+ _ , _ = fmt .Fprintf (hasher , "blob %d\x00 " , len (content ))
271+ _ , _ = hasher .Write (content )
272+ return hex .EncodeToString (hasher .Sum (nil ))
273+ }
274+
275+ func symlinkTargetFromBlob (ctx context.Context , client * github.Client , owner , repo , sha string ) (string , * github.Response , error ) {
276+ target , resp , err := gitBlobBytes (ctx , client , owner , repo , sha )
277+ if err != nil {
278+ return "" , resp , err
279+ }
280+ if len (target ) == 0 || ! utf8 .Valid (target ) || bytes .IndexByte (target , 0 ) >= 0 {
281+ return "" , nil , fmt .Errorf ("git blob %q is not a valid symbolic link target" , sha )
282+ }
283+ return string (target ), nil , nil
284+ }
285+
286+ func gitBlobBytes (ctx context.Context , client * github.Client , owner , repo , sha string ) ([]byte , * github.Response , error ) {
287+ if ! looksLikeSHA (sha ) {
288+ return nil , nil , fmt .Errorf ("malformed Git blob SHA %q" , sha )
289+ }
290+ content , resp , err := client .Git .GetBlobRaw (ctx , owner , repo , sha )
291+ if err != nil {
292+ return nil , resp , err
293+ }
294+ if resp != nil && resp .Body != nil {
295+ _ = resp .Body .Close ()
296+ }
297+ if ! strings .EqualFold (gitBlobSHA1 (content ), sha ) {
298+ return nil , nil , fmt .Errorf ("blob bytes returned by the Git Blobs API do not match SHA %q" , sha )
299+ }
300+ return content , nil , nil
301+ }
302+
303+ func newRepositorySymlink (path , sha , target string , explicit bool ) * repositorySymlink {
304+ return & repositorySymlink {
305+ Path : path ,
306+ SHA : sha ,
307+ Target : target ,
308+ ResolvedTargetPath : resolveRepositorySymlinkTarget (path , target ),
309+ Explicit : explicit ,
310+ }
311+ }
312+
313+ func marshalRepositorySymlinkMetadata (link * repositorySymlink , content , note string ) string {
314+ payload , _ := json .Marshal (repositorySymlinkReadMetadata {
315+ Type : "symlink" ,
316+ Path : link .Path ,
317+ SHA : link .SHA ,
318+ Target : link .Target ,
319+ ResolvedTargetPath : link .ResolvedTargetPath ,
320+ Content : content ,
321+ Note : strings .TrimSpace (note ),
322+ })
323+ return string (payload )
324+ }
325+
326+ func marshalRepositorySubmoduleMetadata (submodule * repositorySubmoduleReadMetadata ) string {
327+ payload , _ := json .Marshal (submodule )
328+ return string (payload )
329+ }
330+
132331func symlinkTargetAtPath (ctx context.Context , client * github.Client , owner , repo , treeish , path string ) (string , bool , * github.Response , error ) {
133332 entry , resp , err := getTreeEntry (ctx , client , owner , repo , treeish , path )
134333 if err != nil {
@@ -141,18 +340,18 @@ func symlinkTargetAtPath(ctx context.Context, client *github.Client, owner, repo
141340 return "" , false , nil , nil
142341 }
143342
144- target , resp , err := client . Git . GetBlobRaw (ctx , owner , repo , entry .GetSHA ())
343+ target , resp , err := gitBlobBytes (ctx , client , owner , repo , entry .GetSHA ())
145344 if err != nil {
146345 return "" , false , resp , err
147346 }
148- if resp != nil && resp .Body != nil {
149- _ = resp .Body .Close ()
150- }
151347 return string (target ), true , nil , nil
152348}
153349
154350func getTreeEntry (ctx context.Context , client * github.Client , owner , repo , treeish , path string ) (* github.TreeEntry , * github.Response , error ) {
155351 segments := strings .Split (pathpkg .Clean (strings .TrimPrefix (path , "/" )), "/" )
352+ if len (segments ) > maxGitTreeTraversalDepth {
353+ return nil , nil , fmt .Errorf ("path %q exceeds the maximum Git tree traversal depth of %d" , path , maxGitTreeTraversalDepth )
354+ }
156355 treeish = escapeGitTreeish (treeish )
157356 for i , segment := range segments {
158357 tree , resp , err := client .Git .GetTree (ctx , owner , repo , treeish , false )
0 commit comments