diff --git a/docs/development.md b/docs/development.md index a172c88..d8d219e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,25 +26,41 @@ Both backends expose the same core endpoints. The Go backend also includes a - body: `{ "url": "https://github.com/user/repo.git" }` - returns: array of commits `[{ hash, message, author, date }]` - `POST /metadata` + - body: `{ "url": "...", "commit": "" }` + - returns: decoded `root.json` and `targets.json` metadata for the + specified commit + - `POST /metadata-single` - body: `{ "url": "...", "commit": "", "file": "root.json|targets.json" }` - returns: decoded metadata JSON from `metadata/` at the specified commit + - `POST /policy-query` + - body: `{ "url": "...", "commit": "", "branch": "main", "changedPath": "src/app.ts" }` + - returns: matched rule, required approvals, and authorized users for the + queried branch/path - Local repository (by folder path) - `POST /commits-local` - body: `{ "path": "/absolute/path/to/local/repo" }` - - returns: array of commits from `HEAD` + - returns: array of commits from `refs/gittuf/policy` - `POST /metadata-local` + - body: `{ "path": "...", "commit": "" }` + - returns: decoded `root.json` and `targets.json` metadata for the + specified commit + - `POST /metadata-local-single` - body: `{ "path": "...", "commit": "", "file": "root.json|targets.json" }` - returns: decoded metadata JSON from `metadata/` at the specified commit + - `POST /policy-query-local` + - body: `{ "path": "...", "commit": "", "branch": "main", "changedPath": "src/app.ts" }` + - returns: matched rule, required approvals, and authorized users for the + queried branch/path - Health (Go backend only) - `GET /health` → `{ "status": "Looks good!" }` Notes: - The backend fetches commits from the `refs/gittuf/policy` ref for - remote repositories. + both remote and local repositories. - Metadata blobs are expected under `metadata/root.json` or `metadata/targets.json` in the tree of the given commit. diff --git a/go-backend/README.md b/go-backend/README.md index af5ae5d..878226c 100644 --- a/go-backend/README.md +++ b/go-backend/README.md @@ -93,7 +93,38 @@ Fetches commits from the `refs/gittuf/policy` branch of a remote repository. #### POST `/metadata` -Retrieves and decodes a metadata blob from a specific commit in a remote repository. +Retrieves and decodes both `root.json` and `targets.json` from a specific commit in a remote repository. + +- **Request Body** (JSON): + ```json + { + "url": "https://github.com/user/repo.git", + "commit": "abc123..." + } + ``` +- **Response** (200 OK): + ```json + { + "root": { + // Decoded root metadata object + }, + "targets": { + // Decoded targets metadata object + } + } + ``` +- **Error Response** (400/500): + ```json + { + "error": "Error message", + "code": 400, + "details": "Detailed error information" + } + ``` + +#### POST `/metadata-single` + +Retrieves and decodes a single metadata blob from a specific commit in a remote repository. - **Request Body** (JSON): ```json @@ -118,13 +149,44 @@ Retrieves and decodes a metadata blob from a specific commit in a remote reposit } ``` +#### POST `/policy-query` + +Evaluates a branch/path query against policy metadata at a specific remote policy commit. + +- **Request Body** (JSON): + ```json + { + "url": "https://github.com/user/repo.git", + "commit": "abc123...", + "branch": "main", + "changedPath": "src/app.ts" + } + ``` +- **Response** (200 OK): + ```json + { + "matchedBranch": "main", + "matchedRule": "src/**", + "requiredApprovals": 2, + "authorizedUsers": ["alice", "bob"] + } + ``` +- **Error Response** (400/500): + ```json + { + "error": "Error message", + "code": 400, + "details": "Detailed error information" + } + ``` + --- ### Local Repository Endpoints #### POST `/commits-local` -Lists commits from the HEAD of a local Git repository. +Lists commits from the `refs/gittuf/policy` ref of a local Git repository. - **Request Body** (JSON): ```json @@ -154,7 +216,38 @@ Lists commits from the HEAD of a local Git repository. #### POST `/metadata-local` -Retrieves and decodes a metadata blob from a specific commit in a local repository. +Retrieves and decodes both `root.json` and `targets.json` from a specific commit in a local repository. + +- **Request Body** (JSON): + ```json + { + "path": "/path/to/local/repo", + "commit": "abc123..." + } + ``` +- **Response** (200 OK): + ```json + { + "root": { + // Decoded root metadata object + }, + "targets": { + // Decoded targets metadata object + } + } + ``` +- **Error Response** (400/500): + ```json + { + "error": "Error message", + "code": 400, + "details": "Detailed error information" + } + ``` + +#### POST `/metadata-local-single` + +Retrieves and decodes a single metadata blob from a specific commit in a local repository. - **Request Body** (JSON): ```json @@ -179,6 +272,37 @@ Retrieves and decodes a metadata blob from a specific commit in a local reposito } ``` +#### POST `/policy-query-local` + +Evaluates a branch/path query against policy metadata at a specific local policy commit. + +- **Request Body** (JSON): + ```json + { + "path": "/path/to/local/repo", + "commit": "abc123...", + "branch": "main", + "changedPath": "src/app.ts" + } + ``` +- **Response** (200 OK): + ```json + { + "matchedBranch": "main", + "matchedRule": "src/**", + "requiredApprovals": 2, + "authorizedUsers": ["alice", "bob"] + } + ``` +- **Error Response** (400/500): + ```json + { + "error": "Error message", + "code": 400, + "details": "Detailed error information" + } + ``` + ## Project Structure ``` @@ -194,4 +318,4 @@ go-backend/ ├── go.mod # Go module definition and dependencies ├── go.sum # Dependencies checksums └── .env # Environment configuration -``` \ No newline at end of file +``` diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index 5a2ca9e..d97abcf 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -38,17 +38,24 @@ func main() { config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"} router.Use(cors.New(config)) + // health check endpoint + router.GET("/health", handlers.Health) + // Remote repository endpoints router.POST("/commits", handlers.ListCommits) router.POST("/metadata", handlers.GetMetadata) + router.POST("/metadata-single", handlers.GetMetadataSingle) + router.POST("/policy-query", handlers.QueryPolicyRemote) // Local repository endpoints router.POST("/commits-local", handlers.ListCommitsLocal) router.POST("/metadata-local", handlers.GetMetadataLocal) + router.POST("/metadata-local-single", handlers.GetMetadataLocalSingle) + router.POST("/policy-query-local", handlers.QueryPolicyLocal) port := os.Getenv("PORT") if port == "" { - port = "5000" + port = "8080" } if err := router.Run(":" + port); err != nil { diff --git a/go-backend/internal/handlers/health.go b/go-backend/internal/handlers/health.go new file mode 100644 index 0000000..a1d9487 --- /dev/null +++ b/go-backend/internal/handlers/health.go @@ -0,0 +1,17 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// simple health check endpoint for the backend server +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "Looks good!", + }) +} diff --git a/go-backend/internal/handlers/helpers.go b/go-backend/internal/handlers/helpers.go new file mode 100644 index 0000000..45c5cd4 --- /dev/null +++ b/go-backend/internal/handlers/helpers.go @@ -0,0 +1,105 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gittuf/visualizer/go-backend/internal/logger" + "github.com/gittuf/visualizer/go-backend/internal/models" + "github.com/gittuf/visualizer/go-backend/internal/services" + "github.com/gittuf/visualizer/go-backend/internal/validation" + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/gin-gonic/gin" +) + +func loadRemoteRepo(c *gin.Context, url, endpoint string) (string, func(), bool) { + repoPath, cleanup, err := services.CloneAndFetchRepo(url) + if err != nil { + logger.Sugar.Errorf("Exception in %s: %v", endpoint, err) + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + Error: "Failed to clone or fetch repository", + Code: http.StatusInternalServerError, + Details: err.Error(), + }) + return "", nil, false + } + + return repoPath, cleanup, true +} + +func validateLocalRepoPath(c *gin.Context, path string) (string, bool) { + absPath, err := validation.GetAbsolutePath(path) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Error: "Invalid path", + Code: http.StatusBadRequest, + Details: err.Error(), + }) + return "", false + } + + if !validation.PathExists(absPath) { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Error: fmt.Sprintf("Path does not exist: %s", absPath), + Code: http.StatusBadRequest, + Details: absPath, + }) + return "", false + } + + if !validation.IsValidGitRepo(absPath) { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Error: fmt.Sprintf("Not a valid Git repository: %s", absPath), + Code: http.StatusBadRequest, + Details: absPath, + }) + return "", false + } + + return absPath, true +} + +func writeBindError(c *gin.Context, err error) { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Error: "Invalid request body", + Code: http.StatusBadRequest, + Details: err.Error(), + }) +} + +func requireFields(c *gin.Context, fields ...string) bool { + missing := make([]string, 0, len(fields)/2) + for i := 0; i+1 < len(fields); i += 2 { + if fields[i+1] == "" { + missing = append(missing, fmt.Sprintf("'%s'", fields[i])) + } + } + if len(missing) == 0 { + return true + } + + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + Error: fmt.Sprintf("Missing required field(s): %s", strings.Join(missing, ", ")), + Code: http.StatusBadRequest, + }) + return false +} + +func writeMetadataError(c *gin.Context, action string, err error) { + status := http.StatusInternalServerError + if errors.Is(err, object.ErrFileNotFound) { + status = http.StatusNotFound + } + + c.JSON(status, models.ErrorResponse{ + Error: action, + Code: status, + Details: err.Error(), + }) +} diff --git a/go-backend/internal/handlers/local.go b/go-backend/internal/handlers/local.go index 5566748..0505c9f 100644 --- a/go-backend/internal/handlers/local.go +++ b/go-backend/internal/handlers/local.go @@ -4,60 +4,32 @@ package handlers import ( - "fmt" "net/http" "github.com/gittuf/visualizer/go-backend/internal/logger" "github.com/gittuf/visualizer/go-backend/internal/models" "github.com/gittuf/visualizer/go-backend/internal/services" - "github.com/gittuf/visualizer/go-backend/internal/validation" "github.com/gin-gonic/gin" ) -// Lists commits from local repo's HEAD +// Lists commits from local repo func ListCommitsLocal(c *gin.Context) { var req models.CommitsLocalRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Missing 'path' in request body", - Code: http.StatusBadRequest, - }) - return - } - - // Get absolute path - absPath, err := validation.GetAbsolutePath(req.Path) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Invalid path", - Code: http.StatusInternalServerError, - Details: err.Error(), - }) + writeBindError(c, err) return } - - // Check if path exists - if !validation.PathExists(absPath) { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: fmt.Sprintf("Path does not exist: %s", absPath), - Code: http.StatusBadRequest, - Details: absPath, - }) + if !requireFields(c, "path", req.Path) { return } - // Check if it's a valid git repository - if !validation.IsValidGitRepo(absPath) { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: fmt.Sprintf("Not a valid Git repository: %s", absPath), - Code: http.StatusBadRequest, - Details: absPath, - }) + absPath, ok := validateLocalRepoPath(c, req.Path) + if !ok { return } - // Get commits from HEAD + // Get commits from the local gittuf policy ref commits, err := services.GetLocalCommits(absPath) if err != nil { logger.Sugar.Errorf("Exception in /commits-local: %v", err) @@ -72,59 +44,80 @@ func ListCommitsLocal(c *gin.Context) { c.JSON(http.StatusOK, commits) } -// Retrieves decoded metadata from local repo +// Retrieves decoded metadata from local repo, requires commit to have both root.json and targets.json func GetMetadataLocal(c *gin.Context) { var req models.MetadataLocalRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Missing 'path', 'commit', or 'file' in request body", - Code: http.StatusBadRequest, - }) + writeBindError(c, err) + return + } + if !requireFields(c, "path", req.Path, "commit", req.Commit) { + return + } + + absPath, ok := validateLocalRepoPath(c, req.Path) + if !ok { return } - // Get absolute path - absPath, err := validation.GetAbsolutePath(req.Path) + metadata, err := services.LoadPolicySnapshot(absPath, req.Commit) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Invalid path", - Code: http.StatusBadRequest, - Details: err.Error(), - }) + logger.Sugar.Errorf("Exception in /metadata-local: %v", err) + writeMetadataError(c, "Failed to fetch metadata", err) return } - // Check if path exists - if !validation.PathExists(absPath) { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: fmt.Sprintf("Path does not exist: %s", absPath), - Code: http.StatusBadRequest, - Details: absPath, - }) + c.JSON(http.StatusOK, metadata) +} + +// Retrieves a single decoded metadata file from local repo +func GetMetadataLocalSingle(c *gin.Context) { + var req models.MetadataLocalSingleRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeBindError(c, err) + return + } + if !requireFields(c, "path", req.Path, "commit", req.Commit, "file", req.File) { return } - // Check if it's a valid git repository - if !validation.IsValidGitRepo(absPath) { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: fmt.Sprintf("Not a valid Git repository: %s", absPath), - Code: http.StatusBadRequest, - Details: absPath, - }) + absPath, ok := validateLocalRepoPath(c, req.Path) + if !ok { return } - // Decode the metadata blob metadata, err := services.DecodeMetadataBlob(absPath, req.Commit, req.File) if err != nil { - logger.Sugar.Errorf("Exception in /metadata-local: %v", err) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - Error: "Failed to fetch metadata", - Code: http.StatusInternalServerError, - Details: err.Error(), - }) + logger.Sugar.Errorf("Exception in /metadata-local-single: %v", err) + writeMetadataError(c, "Failed to fetch metadata", err) return } c.JSON(http.StatusOK, metadata) } + +// Queries policy from local repo +func QueryPolicyLocal(c *gin.Context) { + var req models.PolicyQueryLocalRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeBindError(c, err) + return + } + if !requireFields(c, "path", req.Path, "commit", req.Commit, "branch", req.Branch, "changedPath", req.ChangedPath) { + return + } + + absPath, ok := validateLocalRepoPath(c, req.Path) + if !ok { + return + } + + snapshot, err := services.LoadPolicySnapshot(absPath, req.Commit) + if err != nil { + logger.Sugar.Errorf("Exception in /policy-query-local: %v", err) + writeMetadataError(c, "Failed to load policy metadata", err) + return + } + + c.JSON(http.StatusOK, services.QueryPolicy(snapshot, req.Branch, req.ChangedPath)) +} diff --git a/go-backend/internal/handlers/remote.go b/go-backend/internal/handlers/remote.go index a2ade12..5aa33bb 100644 --- a/go-backend/internal/handlers/remote.go +++ b/go-backend/internal/handlers/remote.go @@ -17,22 +17,16 @@ import ( func ListCommits(c *gin.Context) { var req models.CommitsRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Missing 'url' in request body", - Code: http.StatusBadRequest, - }) + writeBindError(c, err) + return + } + if !requireFields(c, "url", req.URL) { return } // Clone and fetch the repository - repoPath, cleanup, err := services.CloneAndFetchRepo(req.URL) - if err != nil { - logger.Sugar.Errorf("Exception in /commits: %v", err) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - Error: "Failed to clone or fetch repository", - Code: http.StatusInternalServerError, - Details: err.Error(), - }) + repoPath, cleanup, ok := loadRemoteRepo(c, req.URL, "/commits") + if !ok { return } defer cleanup() @@ -56,37 +50,79 @@ func ListCommits(c *gin.Context) { func GetMetadata(c *gin.Context) { var req models.MetadataRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Missing 'url', 'commit', or 'file' in request body", - Code: http.StatusBadRequest, - }) + writeBindError(c, err) + return + } + if !requireFields(c, "url", req.URL, "commit", req.Commit) { return } // Clone and fetch the repository - repoPath, cleanup, err := services.CloneAndFetchRepo(req.URL) + repoPath, cleanup, ok := loadRemoteRepo(c, req.URL, "/metadata") + if !ok { + return + } + defer cleanup() + + metadata, err := services.LoadPolicySnapshot(repoPath, req.Commit) if err != nil { logger.Sugar.Errorf("Exception in /metadata: %v", err) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - Error: "Failed to clone or fetch repository", - Code: http.StatusInternalServerError, - Details: err.Error(), - }) + writeMetadataError(c, "Failed to decode metadata", err) + return + } + + c.JSON(http.StatusOK, metadata) +} + +// Retrieves a single decoded metadata file from remote repo +func GetMetadataSingle(c *gin.Context) { + var req models.MetadataSingleRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeBindError(c, err) + return + } + if !requireFields(c, "url", req.URL, "commit", req.Commit, "file", req.File) { + return + } + + repoPath, cleanup, ok := loadRemoteRepo(c, req.URL, "/metadata-single") + if !ok { return } defer cleanup() - // Decode the metadata blob metadata, err := services.DecodeMetadataBlob(repoPath, req.Commit, req.File) if err != nil { - logger.Sugar.Errorf("Exception in /metadata: %v", err) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - Error: "Failed to decode metadata", - Code: http.StatusInternalServerError, - Details: err.Error(), - }) + logger.Sugar.Errorf("Exception in /metadata-single: %v", err) + writeMetadataError(c, "Failed to decode metadata", err) return } c.JSON(http.StatusOK, metadata) } + +func QueryPolicyRemote(c *gin.Context) { + var req models.PolicyQueryRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeBindError(c, err) + return + } + if !requireFields(c, "url", req.URL, "commit", req.Commit, "branch", req.Branch, "changedPath", req.ChangedPath) { + return + } + + repoPath, cleanup, ok := loadRemoteRepo(c, req.URL, "/policy-query") + if !ok { + return + } + defer cleanup() + + snapshot, err := services.LoadPolicySnapshot(repoPath, req.Commit) + if err != nil { + logger.Sugar.Errorf("Exception in /policy-query: %v", err) + writeMetadataError(c, "Failed to load policy metadata", err) + return + } + + c.JSON(http.StatusOK, services.QueryPolicy(snapshot, req.Branch, req.ChangedPath)) +} diff --git a/go-backend/internal/models/requests.go b/go-backend/internal/models/requests.go index 53de600..348497c 100644 --- a/go-backend/internal/models/requests.go +++ b/go-backend/internal/models/requests.go @@ -4,21 +4,43 @@ package models type CommitsRequest struct { - URL string `json:"url" binding:"required"` + URL string `json:"url"` } type MetadataRequest struct { - URL string `json:"url" binding:"required"` - Commit string `json:"commit" binding:"required"` - File string `json:"file" binding:"required"` + URL string `json:"url"` + Commit string `json:"commit"` +} + +type MetadataSingleRequest struct { + MetadataRequest + File string `json:"file"` } type CommitsLocalRequest struct { - Path string `json:"path" binding:"required"` + Path string `json:"path"` } type MetadataLocalRequest struct { - Path string `json:"path" binding:"required"` - Commit string `json:"commit" binding:"required"` - File string `json:"file" binding:"required"` + Path string `json:"path"` + Commit string `json:"commit"` +} + +type MetadataLocalSingleRequest struct { + MetadataLocalRequest + File string `json:"file"` +} + +type PolicyQueryRequest struct { + URL string `json:"url"` + Commit string `json:"commit"` + Branch string `json:"branch"` + ChangedPath string `json:"changedPath"` +} + +type PolicyQueryLocalRequest struct { + Path string `json:"path"` + Commit string `json:"commit"` + Branch string `json:"branch"` + ChangedPath string `json:"changedPath"` } diff --git a/go-backend/internal/models/responses.go b/go-backend/internal/models/responses.go index aaea157..29d2087 100644 --- a/go-backend/internal/models/responses.go +++ b/go-backend/internal/models/responses.go @@ -11,3 +11,15 @@ type ErrorResponse struct { type CommitsResponse []Commit type MetadataResponse map[string]interface{} + +type PolicySnapshotResponse struct { + Root MetadataResponse `json:"root"` + Targets MetadataResponse `json:"targets"` +} + +type PolicyQueryResponse struct { + MatchedBranch string `json:"matchedBranch"` + MatchedRule string `json:"matchedRule"` + RequiredApprovals int `json:"requiredApprovals"` + AuthorizedUsers []string `json:"authorizedUsers"` +} diff --git a/go-backend/internal/services/git.go b/go-backend/internal/services/git.go index 9cf22ae..0da09bd 100644 --- a/go-backend/internal/services/git.go +++ b/go-backend/internal/services/git.go @@ -13,6 +13,7 @@ import ( "github.com/gittuf/visualizer/go-backend/internal/models" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" ) @@ -54,48 +55,23 @@ func CloneAndFetchRepo(url string) (string, func(), error) { // Retrieve commits from the gittuf/policy ref func GetPolicyCommits(repoPath string) ([]models.Commit, error) { - repo, err := git.PlainOpen(repoPath) - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - ref, err := repo.Reference("refs/remotes/origin/gittuf/policy", true) - if err != nil { - return nil, fmt.Errorf("failed to get policy ref: %w", err) - } - - commitIter, err := repo.Log(&git.LogOptions{From: ref.Hash()}) - if err != nil { - return nil, fmt.Errorf("failed to get commit log: %w", err) - } - - var commits []models.Commit - err = commitIter.ForEach(func(c *object.Commit) error { - commits = append(commits, models.Commit{ - Hash: c.Hash.String(), - Message: strings.TrimSpace(c.Message), - Author: c.Author.Name, - Date: c.Author.When, - }) - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to iterate commits: %w", err) - } - - return commits, nil + return getCommitsForRef(repoPath, plumbing.ReferenceName("refs/remotes/origin/gittuf/policy")) } -// Retrieve commits from HEAD of a local repository +// Retrieve commits from the local gittuf policy ref func GetLocalCommits(repoPath string) ([]models.Commit, error) { + return getCommitsForRef(repoPath, plumbing.ReferenceName("refs/gittuf/policy")) +} + +func getCommitsForRef(repoPath string, refName plumbing.ReferenceName) ([]models.Commit, error) { repo, err := git.PlainOpen(repoPath) if err != nil { return nil, fmt.Errorf("failed to open repository: %w", err) } - ref, err := repo.Head() + ref, err := repo.Reference(refName, true) if err != nil { - return nil, fmt.Errorf("failed to get HEAD: %w", err) + return nil, fmt.Errorf("failed to get %s: %w", refName, err) } commitIter, err := repo.Log(&git.LogOptions{From: ref.Hash()}) diff --git a/go-backend/internal/services/metadata.go b/go-backend/internal/services/metadata.go index 54c757e..c3ad6b7 100644 --- a/go-backend/internal/services/metadata.go +++ b/go-backend/internal/services/metadata.go @@ -42,7 +42,7 @@ func DecodeMetadataBlob(repoPath, commitHash, metadataFilename string) (models.M file, err := tree.File(metadataPath) if err != nil { if errors.Is(err, object.ErrFileNotFound) { - return nil, fmt.Errorf("file metadata/%s not found in commit", metadataFilename) + return nil, fmt.Errorf("file metadata/%s not found in commit: %w", metadataFilename, err) } return nil, fmt.Errorf("failed to get file: %w", err) } diff --git a/go-backend/internal/services/policy.go b/go-backend/internal/services/policy.go new file mode 100644 index 0000000..4c34d48 --- /dev/null +++ b/go-backend/internal/services/policy.go @@ -0,0 +1,257 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package services + +import ( + "sort" + "strings" + + "github.com/gittuf/visualizer/go-backend/internal/models" +) + +type policyRole struct { + Name string + Paths []string + PrincipalIDs []string + Threshold int +} + +// loads the decoded root and targets metadata for a commit. +func LoadPolicySnapshot(repoPath, commitHash string) (models.PolicySnapshotResponse, error) { + root, err := DecodeMetadataBlob(repoPath, commitHash, "root.json") + if err != nil { + return models.PolicySnapshotResponse{}, err + } + + targets, err := DecodeMetadataBlob(repoPath, commitHash, "targets.json") + if err != nil { + return models.PolicySnapshotResponse{}, err + } + + return models.PolicySnapshotResponse{ + Root: root, + Targets: targets, + }, nil +} + +// find the matching rule for a branch/path query and returns its required approvals and auth users. +func QueryPolicy(snapshot models.PolicySnapshotResponse, branch, changedPath string) models.PolicyQueryResponse { + role := findMatchingRole(snapshot.Targets, branch, changedPath) + if role == nil { + return models.PolicyQueryResponse{ + MatchedBranch: branch, + MatchedRule: changedPath, + RequiredApprovals: 0, + AuthorizedUsers: []string{}, + } + } + + matchedRule := role.Name + if matchedRule == "" { + matchedRule = firstNonEmpty(role.Paths, changedPath) + } + + return models.PolicyQueryResponse{ + MatchedBranch: branch, + MatchedRule: matchedRule, + RequiredApprovals: role.Threshold, + AuthorizedUsers: resolveAuthorizedUsers(snapshot, role.PrincipalIDs), + } +} + +// scans targets delegations and picks the first role that matches the branch/path input. +func findMatchingRole(targets models.MetadataResponse, branch, changedPath string) *policyRole { + delegations, ok := targets["delegations"].(map[string]interface{}) + if !ok { + return nil + } + + rawRoles, ok := delegations["roles"].([]interface{}) + if !ok { + return nil + } + + branchRef := normalizeBranch(branch) + var fallback *policyRole + + for _, rawRole := range rawRoles { + roleMap, ok := rawRole.(map[string]interface{}) + if !ok { + continue + } + + role := policyRole{ + Name: stringValue(roleMap["name"]), + Paths: stringSlice(roleMap["paths"]), + PrincipalIDs: stringSlice(roleMap["principalIDs"]), + Threshold: intValue(roleMap["threshold"]), + } + + if len(role.Paths) == 0 { + continue + } + + matchesBranch := false + matchesPath := false + hasBranchPattern := false + + for _, pattern := range role.Paths { + if strings.HasPrefix(pattern, "git:refs/heads/") { + hasBranchPattern = true + if pattern == branchRef { + matchesBranch = true + } + continue + } + + if matchesPattern(pattern, changedPath) { + matchesPath = true + } + } + + if hasBranchPattern { + if matchesBranch && (matchesPath || onlyBranchPatterns(role.Paths)) { + return &role + } + continue + } + + if matchesPath { + return &role + } + + if fallback == nil && containsWildcard(role.Paths) { + roleCopy := role + fallback = &roleCopy + } + } + + return fallback +} + +// maps principal IDs from a role to user-facing names when possible. +func resolveAuthorizedUsers(snapshot models.PolicySnapshotResponse, principalIDs []string) []string { + if len(principalIDs) == 0 { + return []string{} + } + + results := make([]string, 0, len(principalIDs)) + seen := map[string]struct{}{} + + targetsDelegations, _ := snapshot.Targets["delegations"].(map[string]interface{}) + targetsPrincipals, _ := targetsDelegations["principals"].(map[string]interface{}) + rootPrincipals, _ := snapshot.Root["principals"].(map[string]interface{}) + + for _, principalID := range principalIDs { + name := principalID + + if principal, ok := targetsPrincipals[principalID].(map[string]interface{}); ok { + if personID := stringValue(principal["personID"]); personID != "" { + name = personID + } + } else if _, ok := rootPrincipals[principalID]; ok { + name = principalID + } + + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + results = append(results, name) + } + + sort.Strings(results) + return results +} + +// converts a branch name into the git ref format used by policy rules. +func normalizeBranch(branch string) string { + if strings.HasPrefix(branch, "refs/heads/") { + return "git:" + branch + } + return "git:refs/heads/" + strings.TrimPrefix(branch, "git:refs/heads/") +} + +// tells you whether every pattern in the rule is a branch ref pattern. +func onlyBranchPatterns(patterns []string) bool { + for _, pattern := range patterns { + if !strings.HasPrefix(pattern, "git:refs/heads/") { + return false + } + } + return true +} + +// tells you whether a rule contains a catch-all path pattern. +func containsWildcard(patterns []string) bool { + for _, pattern := range patterns { + if pattern == "*" || pattern == "**" { + return true + } + } + return false +} + +func matchesPattern(pattern, value string) bool { + switch { + case pattern == "*" || pattern == "**": + return true + case strings.HasSuffix(pattern, "/**"): + prefix := strings.TrimSuffix(pattern, "/**") + return value == prefix || strings.HasPrefix(value, prefix+"/") + case strings.HasSuffix(pattern, "/*"): + prefix := strings.TrimSuffix(pattern, "/*") + if !strings.HasPrefix(value, prefix+"/") { + return false + } + rest := strings.TrimPrefix(value, prefix+"/") + return rest != "" && !strings.Contains(rest, "/") + default: + return value == pattern + } +} + +// converts a decoded JSON array into a slice of strings. +func stringSlice(value interface{}) []string { + items, ok := value.([]interface{}) + if !ok { + return nil + } + + result := make([]string, 0, len(items)) + for _, item := range items { + if str, ok := item.(string); ok && str != "" { + result = append(result, str) + } + } + return result +} + +// reads a string from a decoded JSON field. +func stringValue(value interface{}) string { + str, _ := value.(string) + return str +} + +// reads an integer value from a decoded JSON field. +func intValue(value interface{}) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + default: + return 0 + } +} + +// returns the first non-empty string in a slice or a fallback value. +func firstNonEmpty(values []string, fallback string) string { + for _, value := range values { + if value != "" { + return value + } + } + return fallback +} diff --git a/go-backend/internal/validation/validation.go b/go-backend/internal/validation/validation.go index 00b118b..79e16e2 100644 --- a/go-backend/internal/validation/validation.go +++ b/go-backend/internal/validation/validation.go @@ -4,16 +4,31 @@ package validation import ( + "errors" "os" "path/filepath" + "strings" ) +var ErrInvalidLocalPath = errors.New("path must be an absolute local filesystem path") + func GetAbsolutePath(path string) (string, error) { - absPath, err := filepath.Abs(path) - if err != nil { - return "", err + path = strings.TrimSpace(path) + if path == "" { + return "", ErrInvalidLocalPath + } + + // Reject URL-like and UNC/network-style paths. Local endpoints should only + // read from the machine's filesystem. + if strings.Contains(path, "://") || strings.HasPrefix(path, "\\\\") || strings.ContainsRune(path, '\x00') { + return "", ErrInvalidLocalPath } - return absPath, nil + + if !filepath.IsAbs(path) { + return "", ErrInvalidLocalPath + } + + return filepath.Clean(path), nil } func IsValidGitRepo(path string) bool { diff --git a/go-backend/internal/validation/validation_test.go b/go-backend/internal/validation/validation_test.go new file mode 100644 index 0000000..ea3c70f --- /dev/null +++ b/go-backend/internal/validation/validation_test.go @@ -0,0 +1,71 @@ +// Copyright The gittuf Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "path/filepath" + "testing" +) + +func TestGetAbsolutePath(t *testing.T) { + t.Parallel() + + validPath := filepath.Join(string(filepath.Separator), "tmp", "repo", "..", "repo") + + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "accepts absolute local path", + input: validPath, + want: filepath.Clean(validPath), + }, + { + name: "rejects relative path", + input: "repo", + wantErr: true, + }, + { + name: "rejects url style path", + input: "https://invalid.example/repo", + wantErr: true, + }, + { + name: "rejects unc path", + input: "\\\\server\\share\\repo", + wantErr: true, + }, + { + name: "rejects empty path", + input: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := GetAbsolutePath(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("GetAbsolutePath(%q) expected error", tt.input) + } + return + } + + if err != nil { + t.Fatalf("GetAbsolutePath(%q) unexpected error: %v", tt.input, err) + } + + if got != tt.want { + t.Fatalf("GetAbsolutePath(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}