From 5147c6bddb8dec9138db044d6e7bb19c46af8f00 Mon Sep 17 00:00:00 2001 From: erangel1 Date: Sun, 17 May 2026 20:09:55 +0200 Subject: [PATCH] added git ssh support and ablity to download repo via zip, tar.gz, and bundle --- .gitignore | 2 - cmd/forgebucket/main.go | 4 + docker-compose.prod.yml | 5 +- frontend/src/api/queries/instance.ts | 23 ++++ frontend/src/pages/RepoPage.tsx | 96 ++++++++++--- internal/api/handlers/archive.go | 61 ++++++++ internal/api/handlers/instance.go | 45 ++++++ internal/api/router.go | 4 + internal/config/config.go | 7 + internal/domain/git/binary.go | 41 ++++++ internal/domain/sshserver/auth.go | 45 ++++++ internal/domain/sshserver/server.go | 121 ++++++++++++++++ internal/domain/sshserver/session.go | 199 +++++++++++++++++++++++++++ 13 files changed, 633 insertions(+), 20 deletions(-) create mode 100644 frontend/src/api/queries/instance.ts create mode 100644 internal/api/handlers/archive.go create mode 100644 internal/api/handlers/instance.go create mode 100644 internal/domain/sshserver/auth.go create mode 100644 internal/domain/sshserver/server.go create mode 100644 internal/domain/sshserver/session.go diff --git a/.gitignore b/.gitignore index 54a5b06..c50fd18 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,4 @@ uploads # Database *.db - -ai_agent_master_prompt_for_building_modern_git_platform.md html docs/ diff --git a/cmd/forgebucket/main.go b/cmd/forgebucket/main.go index 72fc116..ed3aa9c 100644 --- a/cmd/forgebucket/main.go +++ b/cmd/forgebucket/main.go @@ -21,6 +21,7 @@ import ( gitdomain "github.com/forgeo/forgebucket/internal/domain/git" "github.com/forgeo/forgebucket/internal/domain/gitops" "github.com/forgeo/forgebucket/internal/domain/oci" + "github.com/forgeo/forgebucket/internal/domain/sshserver" "github.com/forgeo/forgebucket/internal/domain/scanning" "github.com/forgeo/forgebucket/internal/domain/vulnscan" "github.com/forgeo/forgebucket/internal/domain/sbom" @@ -94,6 +95,9 @@ func main() { go observability.StartNATSWatcher(ciCtx, bus) + sshSrv := sshserver.New(engine, cfg) + go sshSrv.ListenAndServe(ciCtx) //nolint:errcheck + // Initialise artifact signing key store. var keyStore *signing.KeyStore if cfg.ArtifactSigningKey != "" { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b2f2701..e60c8cb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,7 +14,7 @@ services: interval: 5s timeout: 5s retries: 10 - + nats: image: mirror.gcr.io/nats:2-alpine restart: unless-stopped @@ -27,7 +27,7 @@ services: interval: 5s timeout: 5s retries: 10 - + app: build: . container_name: fb-app @@ -40,6 +40,7 @@ services: DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable ports: - "8080:8080" + - "2222:22" volumes: - fb_repo_data:/tmp/forgebucket/repos - fb_oci_data:/tmp/forgebucket/oci diff --git a/frontend/src/api/queries/instance.ts b/frontend/src/api/queries/instance.ts new file mode 100644 index 0000000..be0d3df --- /dev/null +++ b/frontend/src/api/queries/instance.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' +import { api } from '../client' + +export interface InstanceConfig { + sshHost: string + sshPort: string + instanceName: string +} + +const instanceSchema = z.object({ + sshHost: z.string(), + sshPort: z.string(), + instanceName: z.string(), +}) + +export function useInstance() { + return useQuery({ + queryKey: ['instance'], + queryFn: () => api.get('/api/v1/instance', instanceSchema), + staleTime: Infinity, + }) +} diff --git a/frontend/src/pages/RepoPage.tsx b/frontend/src/pages/RepoPage.tsx index 5cc552f..0c767e1 100644 --- a/frontend/src/pages/RepoPage.tsx +++ b/frontend/src/pages/RepoPage.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos' import { useEnvironments } from '../api/queries/environments' +import { useInstance } from '../api/queries/instance' import { TreeBrowser } from '../components/repos/TreeBrowser' import { RepoListSkeleton } from '../ui/Skeleton' import { RepoAvatar } from '../ui/RepoAvatar' @@ -14,6 +15,7 @@ export default function RepoPage() { const [searchParams, setSearchParams] = useSearchParams() const [showBranches, setShowBranches] = useState(false) const [showClone, setShowClone] = useState(false) + const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https') const branchRef = useRef(null) const cloneRef = useRef(null) @@ -23,6 +25,7 @@ export default function RepoPage() { const { data: repo, isLoading, isError } = useRepo(owner, repoName) const { data: branches } = useRepoBranches(owner, repoName) const { data: environments } = useEnvironments(owner, repoName) + const { data: instance } = useInstance() const { track } = useRecentRepos() useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName]) @@ -42,6 +45,14 @@ export default function RepoPage() { const branch = ref || repo.defaultBranch const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git` + const sshHost = instance?.sshHost ?? window.location.hostname + const sshPort = instance?.sshPort ?? '2222' + const sshUrl = sshPort === '22' + ? `git@${sshHost}:${owner}/${repoName}.git` + : `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git` + + const archiveBase = `/api/v1/repos/${owner}/${repoName}/archive?ref=${encodeURIComponent(branch)}` + function switchBranch(b: string) { setSearchParams({ ref: b, ...(path ? { path } : {}) }) setShowBranches(false) @@ -123,17 +134,64 @@ export default function RepoPage() { {showClone && ( -
-

Clone over HTTP

-
- {cloneUrl} - +
+ + {/* Clone URL tabs */} +
+
+ {(['https', 'ssh'] as const).map(tab => ( + + ))} +
+
+ + {cloneTab === 'https' ? cloneUrl : sshUrl} + + +
+ {cloneTab === 'ssh' && ( +

+ Requires an SSH key added to your account settings. +

+ )}
+ + {/* Archive download */} +
+

Download

+
+ {[ + { label: 'ZIP', format: 'zip' }, + { label: 'tar.gz', format: 'tar.gz' }, + { label: 'Bundle', format: 'bundle' }, + ].map(({ label, format }) => ( + + {label} + + ))} +
+
+
)}
@@ -141,7 +199,7 @@ export default function RepoPage() {
{repo.isEmpty ? ( - + ) : ( <> {/* Branch selector */} @@ -231,8 +289,8 @@ function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref: ) } -function GettingStarted({ repoName, branch, cloneUrl }: { - repoName: string; branch: string; cloneUrl: string +function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: { + repoName: string; branch: string; cloneUrl: string; sshUrl: string }) { return (
@@ -241,9 +299,15 @@ function GettingStarted({ repoName, branch, cloneUrl }: {

Push your first commit to get started.

-
-

Clone over HTTP

- +
+
+

Clone over HTTPS

+ +
+
+

Clone over SSH

+ +

…or push an existing repository

diff --git a/internal/api/handlers/archive.go b/internal/api/handlers/archive.go new file mode 100644 index 0000000..79b8916 --- /dev/null +++ b/internal/api/handlers/archive.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "fmt" + "net/http" + + "xorm.io/xorm" + + gitdomain "github.com/forgeo/forgebucket/internal/domain/git" +) + +type ArchiveHandler struct { + db *xorm.Engine +} + +func NewArchiveHandler(db *xorm.Engine) *ArchiveHandler { + return &ArchiveHandler{db: db} +} + +var archiveFormats = map[string]struct { + contentType string + ext string +}{ + "zip": {"application/zip", "zip"}, + "tar.gz": {"application/x-tar", "tar.gz"}, + "bundle": {"application/octet-stream", "bundle"}, +} + +func (h *ArchiveHandler) Download(w http.ResponseWriter, r *http.Request) { + repo, ok := resolveRepo(h.db, w, r) + if !ok { + return + } + + format := r.URL.Query().Get("format") + if format == "" { + format = "zip" + } + meta, allowed := archiveFormats[format] + if !allowed { + jsonError(w, "format must be zip, tar.gz, or bundle", http.StatusBadRequest) + return + } + + ref := r.URL.Query().Get("ref") + if ref == "" { + ref = repo.DefaultBranch + } + if ref == "" { + ref = "HEAD" + } + + filename := fmt.Sprintf("%s-%s.%s", repo.Name, ref, meta.ext) + w.Header().Set("Content-Type", meta.contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + + if err := gitdomain.ArchiveStream(repo.DiskPath, ref, format, w); err != nil { + // Headers already written — can't change status code; just log and close. + _ = err + } +} diff --git a/internal/api/handlers/instance.go b/internal/api/handlers/instance.go new file mode 100644 index 0000000..7f6a64c --- /dev/null +++ b/internal/api/handlers/instance.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/forgeo/forgebucket/internal/config" +) + +type InstanceHandler struct { + cfg *config.Config +} + +func NewInstanceHandler(cfg *config.Config) *InstanceHandler { + return &InstanceHandler{cfg: cfg} +} + +// Get returns the public instance configuration needed by the frontend to +// construct clone URLs (SSH host, SSH port, instance name). +func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) { + sshHost := h.sshHost(r) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck + "sshHost": sshHost, + "sshPort": h.cfg.SSHPort, + "instanceName": h.cfg.InstanceName, + }) +} + +// sshHost extracts the hostname from InstanceURL. Falls back to the request +// host when InstanceURL is unset (common in local development). +func (h *InstanceHandler) sshHost(r *http.Request) string { + if h.cfg.InstanceURL != "" { + if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" { + return u.Hostname() + } + } + // Strip port from Host header if present. + host := r.Host + if u, err := url.Parse("http://" + host); err == nil { + return u.Hostname() + } + return host +} diff --git a/internal/api/router.go b/internal/api/router.go index 4b0d846..ec3d3b3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -79,6 +79,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry) scanH := handlers.NewScanningHandler(engine, scanner) vulnH := handlers.NewVulnScanHandler(engine, vulnScanner) + archiveH := handlers.NewArchiveHandler(engine) + instanceH := handlers.NewInstanceHandler(cfg) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // Regex constraint ensures only *.git paths match, so asset/SPA URLs @@ -99,6 +101,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even // ── Public ──────────────────────────────────────────────────────────── r.Get("/explore/repos", exploreH.Repos) r.Get("/explore/users", exploreH.Users) + r.Get("/instance", instanceH.Get) // Generates a CSRF token + cookie. SPA calls this once on load. r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) { @@ -177,6 +180,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even r.With(csrf).Put("/blob", repoH.UpdateBlob) r.Get("/commits", repoH.Commits) r.Get("/branches", repoH.Branches) + r.Get("/archive", archiveH.Download) r.Get("/diff", repoH.Diff) r.Route("/pulls", func(r chi.Router) { r.Get("/", prH.List) diff --git a/internal/config/config.go b/internal/config/config.go index f1700d0..30270a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,10 @@ type Config struct { // OCI Registry OCIRoot string + // SSH server + SSHPort string // env: SSH_PORT, default "2222" + SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral + // Dev Debug bool } @@ -68,6 +72,9 @@ func Load() (*Config, error) { cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing) cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing) + cfg.SSHPort = getEnv("SSH_PORT", "2222") + cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH") + // Optional signing key cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY") cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci")) diff --git a/internal/domain/git/binary.go b/internal/domain/git/binary.go index f3732ac..b615ea0 100644 --- a/internal/domain/git/binary.go +++ b/internal/domain/git/binary.go @@ -3,6 +3,7 @@ package git import ( "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -467,3 +468,43 @@ func parseUnifiedDiff(raw string) []FileDiff { commit() return files } + +// ArchiveStream writes a git archive of ref in the requested format to w. +// format must be one of "zip", "tar.gz", or "bundle". +// Output is streamed directly to w without buffering. +func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error { + clean := filepath.Clean(repoPath) + if repoRoot != "" { + root := repoRoot + string(filepath.Separator) + if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot { + return ErrPathTraversal + } + } + + baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} + + var cmd *exec.Cmd + switch format { + case "zip", "tar.gz": + cmd = exec.Command("git", "archive", "--format="+format, ref) + case "bundle": + cmd = exec.Command("git", "bundle", "create", "-", "--all") + default: + return fmt.Errorf("git archive: unsupported format %q", format) + } + + cmd.Dir = clean + cmd.Env = baseEnv + cmd.Stdout = w + + var errBuf strings.Builder + cmd.Stderr = &errBuf + + if err := cmd.Run(); err != nil { + if errBuf.Len() > 0 { + return fmt.Errorf("git archive: %w: %s", err, errBuf.String()) + } + return fmt.Errorf("git archive: %w", err) + } + return nil +} diff --git a/internal/domain/sshserver/auth.go b/internal/domain/sshserver/auth.go new file mode 100644 index 0000000..3c0e119 --- /dev/null +++ b/internal/domain/sshserver/auth.go @@ -0,0 +1,45 @@ +package sshserver + +import ( + "crypto/md5" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" + + "github.com/forgeo/forgebucket/internal/models" +) + +// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of +// the presented key (matching the format stored by the SSH key registration +// handler) and looks it up in the database. +func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + fp := fingerprintMD5(key) + + var sshKey models.SSHKey + if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found { + return nil, fmt.Errorf("unknown key") + } + + // Resolve the username so the session handler can use it for permission checks. + var user models.User + if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found { + return nil, fmt.Errorf("user not found") + } + + return &ssh.Permissions{ + Extensions: map[string]string{ + "username": user.Username, + "user_id": fmt.Sprintf("%d", user.ID), + }, + }, nil +} + +func fingerprintMD5(pub ssh.PublicKey) string { + hash := md5.Sum(pub.Marshal()) + parts := make([]string, len(hash)) + for i, b := range hash { + parts[i] = fmt.Sprintf("%02x", b) + } + return strings.Join(parts, ":") +} diff --git a/internal/domain/sshserver/server.go b/internal/domain/sshserver/server.go new file mode 100644 index 0000000..8fb9841 --- /dev/null +++ b/internal/domain/sshserver/server.go @@ -0,0 +1,121 @@ +// Package sshserver implements an SSH server for git clone/push/pull operations. +// It authenticates users via their stored SSH public keys and executes +// git-upload-pack / git-receive-pack as subprocesses. +package sshserver + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net" + "os" + + "golang.org/x/crypto/ssh" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/config" +) + +// Server is the SSH git server. +type Server struct { + db *xorm.Engine + cfg *config.Config +} + +func New(db *xorm.Engine, cfg *config.Config) *Server { + return &Server{db: db, cfg: cfg} +} + +// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts +// connections until ctx is cancelled. Returns nil when the context is done. +func (s *Server) ListenAndServe(ctx context.Context) error { + hostKey, err := s.loadOrGenerateHostKey() + if err != nil { + return fmt.Errorf("sshserver: host key: %w", err) + } + + srvConfig := &ssh.ServerConfig{ + PublicKeyCallback: s.lookupKey, + } + srvConfig.AddHostKey(hostKey) + + addr := ":" + s.cfg.SSHPort + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err) + return nil + } + log.Printf("sshserver: listening on %s", addr) + + go func() { + <-ctx.Done() + ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + log.Printf("sshserver: accept: %v", err) + continue + } + } + go s.handleConn(conn, srvConfig) + } +} + +func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) { + defer netConn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig) + if err != nil { + return + } + defer sshConn.Close() + + go ssh.DiscardRequests(reqs) + + username, _ := sshConn.Permissions.Extensions["username"] + + for newChan := range chans { + if newChan.ChannelType() != "session" { + newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + return + } + go s.handleSession(ch, requests, username) + } +} + +// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set, +// otherwise generates an ephemeral RSA-4096 key (lost on restart). +func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) { + if s.cfg.SSHHostKeyPath != "" { + data, err := os.ReadFile(s.cfg.SSHHostKeyPath) + if err != nil { + return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err) + } + return ssh.ParsePrivateKey(data) + } + + log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)") + privKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("generate host key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privKey), + }) + return ssh.ParsePrivateKey(keyPEM) +} diff --git a/internal/domain/sshserver/session.go b/internal/domain/sshserver/session.go new file mode 100644 index 0000000..115f739 --- /dev/null +++ b/internal/domain/sshserver/session.go @@ -0,0 +1,199 @@ +package sshserver + +import ( + "encoding/binary" + "fmt" + "io" + "log" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" + + "github.com/forgeo/forgebucket/internal/models" +) + +// handleSession processes a single SSH session channel: waits for an exec +// request, dispatches to the appropriate git subcommand, then exits. +func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) { + defer ch.Close() + + for req := range reqs { + if req.Type != "exec" { + if req.WantReply { + req.Reply(false, nil) //nolint:errcheck + } + continue + } + + cmdStr, err := parseExecPayload(req.Payload) + if err != nil { + req.Reply(false, nil) //nolint:errcheck + return + } + req.Reply(true, nil) //nolint:errcheck + + exitCode := s.runGitCommand(ch, username, cmdStr) + sendExitStatus(ch, uint32(exitCode)) + return + } +} + +// runGitCommand parses the SSH exec command string, validates it, resolves the +// repo, checks permissions, and runs the git subprocess. +func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int { + gitCmd, repoArg, err := parseGitCommand(cmdStr) + if err != nil { + fmt.Fprintf(ch.Stderr(), "error: %v\n", err) + return 1 + } + + // Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git") + path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/") + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 { + fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n") + return 1 + } + ownerName, repoName := parts[0], parts[1] + + repo, err := s.resolveRepo(ownerName, repoName) + if err != nil { + fmt.Fprintf(ch.Stderr(), "error: repository not found\n") + return 1 + } + + // Check permissions. + if gitCmd == "receive-pack" { + if !s.hasPermission(repo, username, "write") { + fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n") + return 1 + } + } else { + // upload-pack: public repos are accessible to all; private repos require read. + if repo.IsPrivate && !s.hasPermission(repo, username, "read") { + fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n") + return 1 + } + } + + // Exec the git subcommand against the bare repo path on disk. + // The disk path comes from the DB — never from user input. + cmd := exec.Command("git", gitCmd, repo.DiskPath) + cmd.Dir = filepath.Clean(repo.DiskPath) + cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"} + + cmd.Stdin = ch + cmd.Stdout = ch + cmd.Stderr = ch.Stderr() + + if err := cmd.Run(); err != nil { + log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err) + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 + } + return 0 +} + +// resolveRepo looks up a repository by owner name (user or workspace) and repo name. +func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) { + var u models.User + if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found { + var repo models.Repository + if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 { + return &repo, nil + } + } + + var ws models.Workspace + if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found { + var repo models.Repository + if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 { + return &repo, nil + } + } + return nil, fmt.Errorf("not found") +} + +// hasPermission checks whether username has at least the required permission on repo. +func (s *Server) hasPermission(repo *models.Repository, username, required string) bool { + var u models.User + if found, _ := s.db.Where("username = ?", username).Get(&u); !found { + return false + } + if u.ID == repo.OwnerID { + return true + } + var m models.RepoMember + if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found { + return false + } + rank := map[string]int{"read": 1, "write": 2, "admin": 3} + return rank[m.Permission] >= rank[required] +} + +// parseGitCommand splits the SSH exec command string into the git subcommand +// and the repo path argument. Only upload-pack and receive-pack are permitted. +// +// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms. +func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) { + cmdStr = strings.TrimSpace(cmdStr) + + var candidate string + var rest string + + if strings.HasPrefix(cmdStr, "git-upload-pack") { + candidate = "upload-pack" + rest = strings.TrimPrefix(cmdStr, "git-upload-pack") + } else if strings.HasPrefix(cmdStr, "git-receive-pack") { + candidate = "receive-pack" + rest = strings.TrimPrefix(cmdStr, "git-receive-pack") + } else if strings.HasPrefix(cmdStr, "git upload-pack") { + candidate = "upload-pack" + rest = strings.TrimPrefix(cmdStr, "git upload-pack") + } else if strings.HasPrefix(cmdStr, "git receive-pack") { + candidate = "receive-pack" + rest = strings.TrimPrefix(cmdStr, "git receive-pack") + } else { + return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed") + } + + // Strip surrounding whitespace and single quotes from the path argument. + rest = strings.TrimSpace(rest) + rest = strings.Trim(rest, "'\"") + if rest == "" { + return "", "", fmt.Errorf("missing repository path argument") + } + + return candidate, rest, nil +} + +// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian +// length followed by the command string. +func parseExecPayload(payload []byte) (string, error) { + if len(payload) < 4 { + return "", fmt.Errorf("exec payload too short") + } + length := binary.BigEndian.Uint32(payload[:4]) + if int(length) > len(payload)-4 { + return "", fmt.Errorf("exec payload length mismatch") + } + return string(payload[4 : 4+length]), nil +} + +// sendExitStatus sends an SSH exit-status channel request. +func sendExitStatus(ch ssh.Channel, code uint32) { + msg := struct{ Status uint32 }{code} + ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck +} + +// Stderr returns the stderr stream of an SSH channel. +// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin; +// Stderr() is defined on *ssh.channel but not the interface — use a type assertion. +func init() { + // Compile-time interface check: ssh.Channel must have Stderr() method. + var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil) +}