diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..1afe48827 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,235 @@ +# AGENTS.md — Soft Serve + +> AI agent guide for [Soft Serve](https://github.com/charmbracelet/soft-serve), a self-hosted Git server with SSH, HTTP, and native git protocol support, plus a built-in TUI. + +## Project Identity + +- **Module:** `github.com/charmbracelet/soft-serve` +- **Language:** Go 1.25+ +- **Binary:** `soft` (entry point: `cmd/soft/main.go`) +- **Database:** SQLite (default) or PostgreSQL, via `jmoiron/sqlx` +- **CLI framework:** `spf13/cobra` (used for both the main CLI and SSH subcommands) +- **SSH framework:** `charm.land/wish/v2` +- **TUI framework:** `charm.land/bubbletea/v2` + `lipgloss/v2` + `glamour/v2` +- **HTTP router:** `gorilla/mux` +- **Config:** YAML file (`$SOFT_SERVE_DATA_PATH/config.yaml`) + env vars (`SOFT_SERVE_*` prefix) + +## Architecture Overview + +Soft Serve runs **four concurrent servers** via `errgroup`: + +| Server | Package | Protocol | Purpose | +|--------|---------|----------|---------| +| SSH | `pkg/ssh/` | SSH | Git push/pull, interactive TUI, admin CLI commands | +| HTTP | `pkg/web/` | HTTP/HTTPS | Git smart HTTP, LFS API, go-get meta, health check | +| Git Daemon | `pkg/daemon/` | Native git (TCP) | Read-only anonymous git access | +| Stats | `pkg/stats/` | HTTP | Prometheus `/metrics` endpoint | + +### Layered Design + +``` +cmd/soft/ → CLI entry points (serve, browse, admin, hook) +pkg/ssh/ → SSH server + middleware + command dispatch +pkg/web/ → HTTP server + middleware + routes +pkg/daemon/ → Git daemon (raw TCP, pktline) +pkg/backend/ → Central business logic orchestrator +pkg/store/ → Data store interface (7 sub-interfaces) +pkg/store/database/ → SQL implementation of Store +pkg/db/ → Database layer (open, migrate, models) +pkg/proto/ → Core domain interfaces (User, Repository) +pkg/access/ → Access level enum (NoAccess → Admin) +pkg/git/ → Git service handlers (upload-pack, receive-pack) +pkg/config/ → Configuration parsing (YAML + env) +pkg/ui/ → TUI components (Bubble Tea models) +``` + +### Canonical Data Flow + +``` +Transport layer (SSH/HTTP/daemon) + → Middleware (context injection, auth, logging) + → Command/Route dispatch + → Backend (business logic) + → Store (data access) + → Database (SQLite/PostgreSQL) +``` + +## Directory Structure + +``` +cmd/ + soft/main.go — Binary entry point + soft/serve/ — `soft serve` command (starts all servers) + soft/admin/ — `soft admin` SSH admin commands + soft/browse/ — `soft browse` TUI browser + soft/hook/ — `soft hook` git hook handler + cmd.go — Shared InitBackendContext / CloseDBContext helpers +git/ — Low-level git operations (repo, commit, tree, tag, refs) +pkg/ + access/ — AccessLevel enum (NoAccess, ReadOnly, ReadWrite, Admin) + backend/ — Central Backend struct: users, repos, auth, LFS, webhooks, cache + config/ — Config struct (YAML + env, SOFT_SERVE_* prefix) + cron/ — Cron scheduler wrapper (robfig/cron) + daemon/ — Git daemon server (TCP, native git protocol) + db/ — Database abstraction (open, migrations, models) + migrate/ — SQL migration files (SQLite + PostgreSQL variants) + models/ — DB model structs + git/ — Git service handlers (upload-pack, receive-pack, LFS transfer) + hooks/ — Git hook generation and interface + jobs/ — Cron job definitions (mirror pull) + jwk/ — JSON Web Key support + lfs/ — Git LFS protocol (client, pointers, scanner, transfers) + log/ — Logger setup + proto/ — Core interfaces (Repository, User, AccessToken, errors) + ssh/ — SSH server, middleware, session handler + cmd/ — 27 SSH subcommands (git, repo, user, token, webhook, settings) + sshutils/ — SSH utility functions + ssrf/ — SSRF protection for outbound requests + stats/ — Prometheus metrics server + storage/ — Storage abstraction (file storage for LFS) + store/ — Store interface (composite of 7 sub-interfaces) + database/ — SQL-backed Store implementation + sync/ — Sync utilities + task/ — Async task manager + ui/ — Bubble Tea TUI components + common/ — Shared TUI state/styles + components/ — Reusable widgets (code, footer, header, selector, statusbar, tabs, viewport) + pages/ — TUI pages (repo list, repo detail) + styles/ — TUI styling + utils/ — General utilities (SanitizeRepo, ValidateRepo) + version/ — Version info + web/ — HTTP server (git smart HTTP, LFS API, auth, health) + webhook/ — Webhook event system (push, branch/tag, repo, collaborator) +testscript/ — Integration tests (testscript framework) +migrations/ — Goose SQL migrations +``` + +## Key Patterns + +### Context-Based Dependency Injection + +All major dependencies are threaded through `context.Context` with typed keys: + +```go +// Injecting +ctx = config.WithContext(ctx, cfg) +ctx = backend.WithContext(ctx, be) + +// Extracting +cfg := config.FromContext(ctx) +be := backend.FromContext(ctx) +``` + +Shared bootstrap in `cmd/cmd.go:InitBackendContext` (used as Cobra `PersistentPreRunE`). + +### Backend as Central Orchestrator + +`pkg/backend/backend.go:Backend` is the single entry point for all business logic. It wraps the Store and provides methods for repos, users, auth, LFS, webhooks, settings, and caching. Handlers and commands call Backend methods — never the Store directly. + +### Store Interface (Composite) + +`pkg/store/store.go:Store` aggregates 7 sub-interfaces: + +- `RepositoryStore` — repo CRUD +- `UserStore` — user CRUD +- `CollaboratorStore` — repo collaborators +- `SettingStore` — server settings +- `LFSStore` — LFS objects/locks +- `AccessTokenStore` — API tokens +- `WebhookStore` — webhook management + +Single SQL implementation in `pkg/store/database/`. + +### Authentication + +**SSH:** Public key verification → `be.UserByPublicKey()`. Keyboard-interactive as keyless fallback. + +**HTTP:** Three schemes via `Authorization` header: +- `Basic` — username/password (bcrypt) or username/access-token +- `Token` — access token directly (`ss_` prefixed, SHA256 hashed) +- `Bearer` — JWT (Ed25519 signed, scoped to repo) + +### Authorization + +Four levels in `pkg/access/access.go`: +- `NoAccess` (0) — denied +- `ReadOnlyAccess` (1) — clone/fetch +- `ReadWriteAccess` (2) — push, LFS locks +- `AdminAccess` (3) — server management + +### SSH Command Dispatch + +Non-PTY SSH sessions go through `CommandMiddleware` which builds a Cobra command tree from `pkg/ssh/cmd/`. The same `cobra.Command` pattern used for the main CLI is reused for SSH commands. + +PTY sessions launch the Bubble Tea TUI via `SessionHandler`. + +### Git Operations + +Git push/pull is handled by shelling out to the `git` binary (not pure Go) via `pkg/git/service.go:gitServiceHandler`. Environment variables pass context (repo name, username, public key) to git hooks. + +### Webhook System + +Events defined in `pkg/webhook/`: push, branch create/delete, tag create/delete, repository create/delete, collaborator add/remove. Delivery via HTTP POST with HMAC-SHA256 signatures. SSRF protection in `pkg/ssrf/`. + +## Build & Test + +```bash +# Build +go build ./cmd/soft + +# Run all tests +go test ./... + +# Run integration tests +go test ./testscript/... + +# Run specific test +go test -run TestName ./pkg/backend/... +``` + +### Test Framework + +- Unit tests: standard Go `testing` + `matryer/is` for assertions +- Integration tests: `rogpeppe/go-internal/testscript` (script-driven, in `testscript/`) +- Test helpers in `pkg/test/` + +## Database + +- **Drivers:** SQLite (`modernc.org/sqlite`, pure Go) and PostgreSQL (`lib/pq`) +- **Access:** `jmoiron/sqlx` with raw SQL queries, `$1`/`$2` placeholders +- **Migrations:** Go-embedded SQL files in `pkg/db/migrate/`, separate `.up.sql`/`.down.sql` per driver +- **Models:** `pkg/db/models/` +- **Transactions:** `db.TransactionContext` helper + +## Configuration + +Loaded from `$SOFT_SERVE_DATA_PATH/config.yaml` with env var overrides (`SOFT_SERVE_*` prefix). Parsed via `caarlos0/env`. Key config sections: + +- `SSH` — listen addr, max timeout, key path +- `HTTP` — listen addr, TLS cert/key, public URL +- `Git` — listen addr, max connections, max timeout +- `Stats` — listen addr +- `LFS` — enabled flag +- `DB` — driver (sqlite/postgres), data source + +## Metrics + +Prometheus metrics throughout the codebase via `promauto`. Exposed at Stats server `/metrics`. Covers SSH connections, HTTP requests, git operations, TUI sessions, LFS transfers. + +## Known TODOs + +- `pkg/backend/backend.go` — proper caching interface (currently basic in-memory) +- `pkg/backend/lfs.go` — S3 storage support for LFS +- `pkg/lfs/ssh_client.go` — Git LFS SSH client (placeholder) +- `pkg/backend/hooks.go` — async hook execution +- `pkg/backend/user.go` — user repository ownership + +## Common Pitfalls + +1. **Never edit `*_templ.go` files** — they are generated. Edit `.templ` sources only. +2. **Context is king** — all dependencies flow through `context.Context`. Use `FromContext` accessors. +3. **Backend, not Store** — handlers call `Backend` methods, never `Store` directly. +4. **Git binary, not go-git** — push/pull shells out to `git`. `go-git` is used for read operations (log, tree, diff). +5. **Dual DB support** — SQL must work on both SQLite and PostgreSQL. Test with both if modifying queries. +6. **SSH commands are Cobra commands** — same pattern as CLI, dispatched in `CommandMiddleware`. +7. **Access levels gate everything** — check `AccessLevel` before any repo operation. diff --git a/cmd/soft/serve/serve.go b/cmd/soft/serve/serve.go index 7472f3d0b..e46acbb53 100644 --- a/cmd/soft/serve/serve.go +++ b/cmd/soft/serve/serve.go @@ -12,7 +12,9 @@ import ( "syscall" "time" + "charm.land/log/v2" "github.com/charmbracelet/soft-serve/cmd" + "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" @@ -70,6 +72,26 @@ var ( return fmt.Errorf("migration error: %w", err) } + // Apply config-driven settings so operators can automate + // server setup without a post-start SSH session. + { + cfgCtx := config.FromContext(ctx) + be := backend.FromContext(ctx) + logger := log.FromContext(ctx).WithPrefix("server") + if cfgCtx.AnonAccess != "" { + if err := be.SetAnonAccess(ctx, access.ParseAccessLevel(cfgCtx.AnonAccess)); err != nil { + return fmt.Errorf("set anon_access: %w", err) + } + logger.Debug("applied anon_access from config", "level", cfgCtx.AnonAccess) + } + if cfgCtx.AllowKeyless != nil { + if err := be.SetAllowKeyless(ctx, *cfgCtx.AllowKeyless); err != nil { + return fmt.Errorf("set allow_keyless: %w", err) + } + logger.Debug("applied allow_keyless from config", "value", *cfgCtx.AllowKeyless) + } + } + s, err := NewServer(ctx) if err != nil { return fmt.Errorf("start server: %w", err) diff --git a/cmd/soft/serve/server.go b/cmd/soft/serve/server.go index fda090050..3365d77df 100644 --- a/cmd/soft/serve/server.go +++ b/cmd/soft/serve/server.go @@ -5,7 +5,10 @@ import ( "crypto/tls" "errors" "fmt" + "net" "net/http" + "sync" + "time" "charm.land/log/v2" @@ -34,8 +37,10 @@ type Server struct { Backend *backend.Backend DB *db.DB - logger *log.Logger - ctx context.Context + logger *log.Logger + ctx context.Context + shutdownOnce sync.Once + shutdownErr error } // NewServer returns a new *Server configured to serve Soft Serve. The SSH @@ -111,48 +116,131 @@ func (s *Server) ReloadCertificates() error { return s.CertLoader.Reload() } -// Start starts the SSH server. +// Start starts all configured servers. func (s *Server) Start() error { - errg, _ := errgroup.WithContext(s.ctx) + // Pre-bind all configured listeners synchronously. If any bind fails + // (e.g. EACCES on a privileged port or address already in use) we + // return an error immediately with a clear message, rather than + // silently continuing while other servers run without the failing one. + var ( + sshLn net.Listener + gitLn net.Listener + httpLn net.Listener + statsLn net.Listener + err error + ) + + // closeAll holds listeners opened so far; cleared once all binds + // succeed so that the defer does not double-close them. + var closeAll []net.Listener + defer func() { + for _, ln := range closeAll { + ln.Close() //nolint:errcheck + } + }() + + if s.Config.SSH.Enabled { + sshLn, err = net.Listen("tcp", s.Config.SSH.ListenAddr) + if err != nil { + return fmt.Errorf("ssh listen %s: %w", s.Config.SSH.ListenAddr, err) + } + closeAll = append(closeAll, sshLn) + } + + if s.Config.Git.Enabled { + gitLn, err = net.Listen("tcp", s.Config.Git.ListenAddr) + if err != nil { + return fmt.Errorf("git daemon listen %s: %w", s.Config.Git.ListenAddr, err) + } + closeAll = append(closeAll, gitLn) + } + + if s.Config.HTTP.Enabled { + httpLn, err = net.Listen("tcp", s.Config.HTTP.ListenAddr) + if err != nil { + return fmt.Errorf("http listen %s: %w", s.Config.HTTP.ListenAddr, err) + } + closeAll = append(closeAll, httpLn) + } + + if s.Config.Stats.Enabled { + statsLn, err = net.Listen("tcp", s.Config.Stats.ListenAddr) + if err != nil { + return fmt.Errorf("stats listen %s: %w", s.Config.Stats.ListenAddr, err) + } + closeAll = append(closeAll, statsLn) + } + + // All binds succeeded; goroutines take ownership of each listener. + closeAll = nil + + errg, gctx := errgroup.WithContext(s.ctx) + + // External monitor goroutine: if any server goroutine returns an + // error, shut down all remaining servers so errg.Wait() unblocks + // and the caller sees the error. Runs *outside* the errgroup so it + // never blocks errg.Wait() itself. + // gctx is always cancelled — either by an erroring goroutine or by + // errg.Wait() itself on return — so this goroutine never leaks. + go func() { + <-gctx.Done() + // errgroup (golang.org/x/sync >= v0.12.0) uses + // context.WithCancelCause internally and calls cancel(g.err) + // on Wait(). If every goroutine returns nil, g.err == nil and + // context.Cause(gctx) == nil — skip to avoid a redundant call. + // If a goroutine returned a non-nil error, Cause != nil and we + // must shut down the remaining servers ourselves. + // + // Invariant: all ErrServerClosed variants MUST be filtered in + // the errg.Go wrappers above; any that leak would incorrectly + // trigger this path. + // + // s.Shutdown is idempotent (shutdownOnce-protected), so it is + // safe if serve.go's SIGTERM handler races to call it first. + if context.Cause(gctx) == nil { + return + } + shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if shutErr := s.Shutdown(shutCtx); shutErr != nil { + s.logger.Error("error shutting down after unexpected server failure", "err", shutErr) + } + }() - // optionally start the SSH server if s.Config.SSH.Enabled { errg.Go(func() error { s.logger.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr) - if err := s.SSHServer.ListenAndServe(); !errors.Is(err, ssh.ErrServerClosed) { + if err := s.SSHServer.Serve(sshLn); !errors.Is(err, ssh.ErrServerClosed) { return err } return nil }) } - // optionally start the git daemon if s.Config.Git.Enabled { errg.Go(func() error { s.logger.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr) - if err := s.GitDaemon.ListenAndServe(); !errors.Is(err, daemon.ErrServerClosed) { + if err := s.GitDaemon.Serve(gitLn); !errors.Is(err, daemon.ErrServerClosed) { return err } return nil }) } - // optionally start the HTTP server if s.Config.HTTP.Enabled { errg.Go(func() error { s.logger.Print("Starting HTTP server", "addr", s.Config.HTTP.ListenAddr) - if err := s.HTTPServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + if err := s.HTTPServer.Serve(httpLn); !errors.Is(err, http.ErrServerClosed) { return err } return nil }) } - // optionally start the Stats server if s.Config.Stats.Enabled { errg.Go(func() error { s.logger.Print("Starting Stats server", "addr", s.Config.Stats.ListenAddr) - if err := s.StatsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + if err := s.StatsServer.Serve(statsLn); !errors.Is(err, http.ErrServerClosed) { return err } return nil @@ -166,8 +254,19 @@ func (s *Server) Start() error { return errg.Wait() } -// Shutdown lets the server gracefully shutdown. +// Shutdown lets the server gracefully shutdown. It is safe to call +// concurrently; only the first call performs the actual shutdown. +// Subsequent callers block until the first completes and then receive +// the same error, so the process exit code reflects the real outcome +// regardless of which path (monitor goroutine or SIGTERM handler) wins. func (s *Server) Shutdown(ctx context.Context) error { + s.shutdownOnce.Do(func() { + s.shutdownErr = s.shutdown(ctx) + }) + return s.shutdownErr +} + +func (s *Server) shutdown(ctx context.Context) error { errg, ctx := errgroup.WithContext(ctx) errg.Go(func() error { return s.GitDaemon.Shutdown(ctx) diff --git a/git/utils.go b/git/utils.go index b4ca50fc6..5e1e72559 100644 --- a/git/utils.go +++ b/git/utils.go @@ -2,6 +2,7 @@ package git import ( "os" + "path" "path/filepath" "github.com/gobwas/glob" @@ -10,7 +11,7 @@ import ( // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path. func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) { g := glob.MustCompile(pattern) - dir := filepath.Dir(pattern) + dir := path.Dir(pattern) if ref == nil { head, err := repo.HEAD() if err != nil { @@ -28,7 +29,7 @@ func LatestFile(repo *Repository, ref *Reference, pattern string) (string, strin } for _, e := range ents { te := e - fp := filepath.Join(dir, te.Name()) + fp := path.Join(dir, te.Name()) if te.IsTree() { continue } diff --git a/pkg/backend/repo.go b/pkg/backend/repo.go index f9b70bbc3..69027246a 100644 --- a/pkg/backend/repo.go +++ b/pkg/backend/repo.go @@ -65,12 +65,25 @@ func (d *Backend) CreateRepository(ctx context.Context, name string, user proto. return err } - _, err := git.Init(rp, true) + rr, err := git.Init(rp, true) if err != nil { d.logger.Debug("failed to create repository", "err", err) return err } + if user != nil && user.Username() != "" { + rcfg, err := rr.Config() + if err != nil { + d.logger.Error("failed to get repository config", "repo", name, "err", err) + return err + } + rcfg.Section("gitweb").SetOption("owner", user.Username()) + if err := rr.SetConfig(rcfg); err != nil { + d.logger.Error("failed to set gitweb.owner", "repo", name, "err", err) + return err + } + } + if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil { d.logger.Error("failed to write description", "repo", name, "err", err) return err diff --git a/pkg/backend/user.go b/pkg/backend/user.go index 736842f9f..297aab203 100644 --- a/pkg/backend/user.go +++ b/pkg/backend/user.go @@ -23,24 +23,6 @@ func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) return d.AccessLevelForUser(ctx, repo, user) } -// AccessLevelByPublicKey returns the access level of a user's public key for a repository. -// -// It implements backend.Backend. -func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel { - for _, k := range d.cfg.AdminKeys() { - if sshutils.KeysEqual(pk, k) { - return access.AdminAccess - } - } - - user, _ := d.UserByPublicKey(ctx, pk) - if user != nil { - return d.AccessLevel(ctx, repo, user.Username()) - } - - return d.AccessLevel(ctx, repo, "") -} - // AccessLevelForUser returns the access level of a user for a repository. // TODO: user repository ownership func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel { diff --git a/pkg/backend/utils.go b/pkg/backend/utils.go index be1b0b4a7..03939c9e6 100644 --- a/pkg/backend/utils.go +++ b/pkg/backend/utils.go @@ -1,6 +1,8 @@ package backend import ( + "errors" + "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/proto" ) @@ -15,9 +17,38 @@ func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, return git.LatestFile(repo, ref, pattern) } +// readmePatterns is the ordered list of glob patterns used to find a README. +// Root-level patterns are checked first; subdirectory paths are fallbacks, +// matching GitHub's README discovery behavior. +var readmePatterns = []string{ + "[rR][eE][aA][dD][mM][eE]", + "[rR][eE][aA][dD][mM][eE].*", + "docs/[rR][eE][aA][dD][mM][eE]", + "docs/[rR][eE][aA][dD][mM][eE].*", + ".github/[rR][eE][aA][dD][mM][eE]", + ".github/[rR][eE][aA][dD][mM][eE].*", + ".gitlab/[rR][eE][aA][dD][mM][eE]", + ".gitlab/[rR][eE][aA][dD][mM][eE].*", +} + // Readme returns the repository's README. +// It checks the repository root first, then falls back to docs/, .github/, +// and .gitlab/ subdirectories. +// When no README is found in any location, it returns ("", "", nil). +// Callers should check whether path is empty to detect a missing README. func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) { - pattern := "[rR][eE][aA][dD][mM][eE]*" - readme, path, err = LatestFile(r, ref, pattern) - return + for _, pattern := range readmePatterns { + readme, path, err = LatestFile(r, ref, pattern) + if err == nil { + return + } + if !errors.Is(err, git.ErrFileNotFound) && + !errors.Is(err, git.ErrRevisionNotExist) && + !errors.Is(err, git.ErrReferenceNotExist) { + return + } + } + // No README found in any location; return a clean sentinel rather than + // leaking the error from the last pattern tried. + return "", "", nil } diff --git a/pkg/backend/utils_test.go b/pkg/backend/utils_test.go new file mode 100644 index 000000000..bf05f99ff --- /dev/null +++ b/pkg/backend/utils_test.go @@ -0,0 +1,213 @@ +package backend + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/charmbracelet/soft-serve/git" +) + +// stubRepo implements proto.Repository backed by a real on-disk git repo. +type stubRepo struct { + path string +} + +func (s *stubRepo) ID() int64 { return 0 } +func (s *stubRepo) Name() string { return "stub" } +func (s *stubRepo) ProjectName() string { return "stub" } +func (s *stubRepo) Description() string { return "" } +func (s *stubRepo) IsPrivate() bool { return false } +func (s *stubRepo) IsMirror() bool { return false } +func (s *stubRepo) IsHidden() bool { return false } +func (s *stubRepo) UserID() int64 { return 0 } +func (s *stubRepo) CreatedAt() time.Time { return time.Time{} } +func (s *stubRepo) UpdatedAt() time.Time { return time.Time{} } +func (s *stubRepo) Open() (*git.Repository, error) { + return git.Open(s.path) +} + +// initTestRepo creates a non-bare git repo in a temp dir, commits the given +// files (map of relative path → content), and returns the path. +func initTestRepo(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + + repo, err := git.Init(dir, false) + if err != nil { + t.Fatalf("git init: %v", err) + } + _ = repo + + for rel, content := range files { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write file %s: %v", rel, err) + } + } + + // git add -A && git commit + cmd := func(args ...string) { + t.Helper() + c := git.NewCommand(args...) + if _, err := c.RunInDir(dir); err != nil { + t.Fatalf("git %v: %v", args, err) + } + } + cmd("add", "-A") + cmd("-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "init") + + return dir +} + +func TestReadme_Root(t *testing.T) { + dir := initTestRepo(t, map[string]string{ + "README.md": "# Root Readme", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "README.md" { + t.Errorf("expected path README.md, got %q", path) + } + if content != "# Root Readme" { + t.Errorf("unexpected content: %q", content) + } +} + +func TestReadme_DocsSubdir(t *testing.T) { + // No root README; only docs/README.md + dir := initTestRepo(t, map[string]string{ + "main.go": "package main", + "docs/README.md": "# Docs Readme", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "docs/README.md" { + t.Errorf("expected path docs/README.md, got %q", path) + } + if content != "# Docs Readme" { + t.Errorf("unexpected content: %q", content) + } +} + +func TestReadme_GithubSubdir(t *testing.T) { + // No root README; only .github/README.md + dir := initTestRepo(t, map[string]string{ + "main.go": "package main", + ".github/README.md": "# GitHub Readme", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != ".github/README.md" { + t.Errorf("expected path .github/README.md, got %q", path) + } + if content != "# GitHub Readme" { + t.Errorf("unexpected content: %q", content) + } +} + +func TestReadme_GitlabSubdir(t *testing.T) { + // No root README; only .gitlab/README.md + dir := initTestRepo(t, map[string]string{ + "main.go": "package main", + ".gitlab/README.md": "# GitLab Readme", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != ".gitlab/README.md" { + t.Errorf("expected path .gitlab/README.md, got %q", path) + } + if content != "# GitLab Readme" { + t.Errorf("unexpected content: %q", content) + } +} + +func TestReadme_RootTakesPrecedence(t *testing.T) { + // Both root and docs have a README; root should win + dir := initTestRepo(t, map[string]string{ + "README.md": "# Root Readme", + "docs/README.md": "# Docs Readme", + }) + repo := &stubRepo{path: dir} + _, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "README.md" { + t.Errorf("expected root README.md to take precedence, got %q", path) + } +} + +func TestReadme_NotFound(t *testing.T) { + dir := initTestRepo(t, map[string]string{ + "main.go": "package main", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error when no readme exists: %v", err) + } + if path != "" || content != "" { + t.Errorf("expected empty path and content when no readme exists, got content=%q path=%q", content, path) + } +} + +// initBareTestRepo creates a bare git repo in a temp dir by initialising a +// non-bare repo, committing the given files, and then cloning it as bare. +func initBareTestRepo(t *testing.T, files map[string]string) string { + t.Helper() + src := initTestRepo(t, files) + bareDir := t.TempDir() + c := git.NewCommand("clone", "--bare", src, bareDir) + if _, err := c.Run(); err != nil { + t.Fatalf("git clone --bare: %v", err) + } + return bareDir +} + +func TestReadme_BareRepo_Root(t *testing.T) { + dir := initBareTestRepo(t, map[string]string{ + "README.md": "# Bare Readme", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "README.md" { + t.Errorf("expected path README.md, got %q", path) + } + if content != "# Bare Readme" { + t.Errorf("unexpected content: %q", content) + } +} + +func TestReadme_BareRepo_NotFound(t *testing.T) { + dir := initBareTestRepo(t, map[string]string{ + "main.go": "package main", + }) + repo := &stubRepo{path: dir} + content, path, err := Readme(repo, nil) + if err != nil { + t.Fatalf("unexpected error when no readme exists in bare repo: %v", err) + } + if path != "" || content != "" { + t.Errorf("expected empty result for bare repo with no readme, got content=%q path=%q", content, path) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 97a5dc43d..e94158294 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "time" "github.com/caarlos0/env/v11" + "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/sshutils" "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" @@ -171,6 +172,16 @@ type Config struct { // InitialAdminKeys is a list of public keys that will be added to the list of admins. InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"` + // AnonAccess is the anonymous access level applied on startup. + // Valid values: "no-access", "read-only", "read-write", "admin-access". + // Leave empty to keep the value stored in the database. + AnonAccess string `env:"ANON_ACCESS" yaml:"anon_access"` + + // AllowKeyless controls whether keyless (keyboard-interactive) access is + // allowed. When set, this overrides the value stored in the database on + // every startup. Leave unset to keep the database value. + AllowKeyless *bool `env:"ALLOW_KEYLESS" yaml:"allow_keyless"` + // DataPath is the path to the directory where Soft Serve will store its data. DataPath string `env:"DATA_PATH" yaml:"-"` } @@ -190,6 +201,7 @@ func (c *Config) Environ() []string { fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath), fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name), fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")), + fmt.Sprintf("SOFT_SERVE_ANON_ACCESS=%s", c.AnonAccess), fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled), fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr), fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL), @@ -443,6 +455,21 @@ func (c *Config) Validate() error { c.InitialAdminKeys = pks + if c.AnonAccess != "" { + level := access.ParseAccessLevel(c.AnonAccess) + // ParseAccessLevel returns NoAccess for unknown strings, but "no-access" + // is also a valid explicit value, so check by re-serialising. + if level.String() != c.AnonAccess { + return fmt.Errorf("invalid anon_access %q: must be one of %s, %s, %s, %s", + c.AnonAccess, + access.NoAccess.String(), + access.ReadOnlyAccess.String(), + access.ReadWriteAccess.String(), + access.AdminAccess.String(), + ) + } + } + c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...) return nil diff --git a/pkg/config/file.go b/pkg/config/file.go index 8170493c3..7e75d740e 100644 --- a/pkg/config/file.go +++ b/pkg/config/file.go @@ -147,6 +147,17 @@ jobs: # Additional admin keys. #initial_admin_keys: # - "ssh-rsa AAAAB3NzaC1yc2..." + +# Anonymous access level applied on every startup. +# Overrides the value stored in the database when set. +# Valid values: no-access, read-only, read-write, admin-access. +# Leave commented out to preserve the database value. +#anon_access: read-only + +# Whether keyless (keyboard-interactive) access is allowed. +# Overrides the value stored in the database when set. +# Leave commented out to preserve the database value. +#allow_keyless: true `)) func newConfigFile(cfg *Config) string { diff --git a/pkg/git/lfs.go b/pkg/git/lfs.go index 14846c120..801f2e8a8 100644 --- a/pkg/git/lfs.go +++ b/pkg/git/lfs.go @@ -147,12 +147,6 @@ func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Arg return err } - obj, err := t.storage.Open(tempName) - if err != nil { - t.logger.Errorf("error opening object: %v", err) - return err - } - pointer := transfer.Pointer{ Oid: oid, } @@ -167,7 +161,7 @@ func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Arg } expectedPath := path.Join("objects", pointer.RelativePath()) - if err := t.storage.Rename(obj.Name(), expectedPath); err != nil { + if err := t.storage.Rename(tempName, expectedPath); err != nil { t.logger.Errorf("error renaming object: %v", err) _ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid) return err diff --git a/pkg/git/lfs_auth.go b/pkg/git/lfs_auth.go index b2175cf3d..9dac4f569 100644 --- a/pkg/git/lfs_auth.go +++ b/pkg/git/lfs_auth.go @@ -54,7 +54,7 @@ func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error { expiresAt := now.Add(expiresIn) claims := jwt.RegisteredClaims{ Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()), - ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour + ExpiresAt: jwt.NewNumericDate(expiresAt), NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), Issuer: cfg.HTTP.PublicURL, @@ -80,6 +80,6 @@ func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error { }, Href: href, ExpiresAt: expiresAt, - ExpiresIn: expiresIn, + ExpiresIn: int64(expiresIn / time.Second), }) } diff --git a/pkg/git/service.go b/pkg/git/service.go index a4066d07e..7ed421042 100644 --- a/pkg/git/service.go +++ b/pkg/git/service.go @@ -9,6 +9,7 @@ import ( "os/exec" "strings" "sync" + "time" "charm.land/log/v2" ) @@ -57,8 +58,27 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error { type ServiceHandler func(ctx context.Context, cmd ServiceCommand) error // gitServiceHandler is the default service handler using the git binary. +// +// Deadline invariant: the ctx passed here must be cancellation-only — it must +// NOT carry a context.WithDeadline or context.WithTimeout. All three transport +// callers (SSH via gliderlabs/ssh, git daemon via pkg/daemon, HTTP via +// net/http) enforce timeouts by calling conn.SetDeadline on the underlying +// net.Conn, NOT by attaching a deadline to the context. If a caller were ever +// changed to pass a deadline-carrying context, exec.CommandContext would kill +// the git subprocess when the deadline expires, potentially corrupting an +// in-progress push. In that case, replace ctx with a context.WithCancelCause +// derived from context.Background() that mirrors parent cancellation but not +// DeadlineExceeded. func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) error { + // NOTE: ctx is cancellation-only (derived from context.Background via + // context.WithCancel). SSH and git-daemon timeouts fire as net.Conn + // deadline errors, not context deadlines — so no deadline can reach here + // and kill a long-running push or clone mid-transfer. cmd := exec.CommandContext(ctx, "git") + // WaitDelay bounds how long we wait for stdin/stdout pipe goroutines to + // finish after the git process has already been killed (e.g. by context + // cancellation). It does NOT impose a timeout on running git operations. + cmd.WaitDelay = 30 * time.Second cmd.Dir = scmd.Dir cmd.Args = append(cmd.Args, []string{ // Enable partial clones @@ -125,8 +145,11 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er if scmd.Stdin != nil { go func() { defer stdin.Close() //nolint: errcheck - if _, err := io.Copy(stdin, scmd.Stdin); err != nil { - log.Errorf("gitServiceHandler: failed to copy stdin: %v", err) + if _, err := io.Copy(stdin, scmd.Stdin); err != nil && ctx.Err() == nil { + // Broken-pipe here is normal: git read all it needed and exited, + // cmd.Wait() closed the write end of the pipe before the client + // finished sending. Log at Debug to avoid spurious error noise. + log.FromContext(ctx).Debug("gitServiceHandler: stdin copy ended", "err", err) } }() } @@ -136,7 +159,7 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er wg.Add(1) go func() { defer wg.Done() - if _, err := io.Copy(scmd.Stdout, stdout); err != nil { + if _, err := io.Copy(scmd.Stdout, stdout); err != nil && ctx.Err() == nil { log.Errorf("gitServiceHandler: failed to copy stdout: %v", err) } }() @@ -147,7 +170,7 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er wg.Add(1) go func() { defer wg.Done() - if _, erro := io.Copy(scmd.Stderr, stderr); err != nil { + if _, erro := io.Copy(scmd.Stderr, stderr); erro != nil && ctx.Err() == nil { log.Errorf("gitServiceHandler: failed to copy stderr: %v", erro) } }() @@ -159,12 +182,39 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er wg.Wait() err = cmd.Wait() - if err != nil && errors.Is(err, os.ErrNotExist) { - return ErrInvalidRepo - } else if err != nil { + if err != nil { var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return fmt.Errorf("%s: %s", exitErr, exitErr.Stderr) + // Note: errors.As correctly unwraps through errors.Join, which Go 1.20+ + // uses when cmd.WaitDelay fires (joining the ExitError with a timeout + // error). Verified: errors.As(errors.Join(exitErr, timeoutErr), &exitErr) + // returns true. So the suppression path below is safe even when WaitDelay + // wraps the ExitError via errors.Join. + if errors.As(err, &exitErr) { + if errors.Is(ctx.Err(), context.Canceled) { + // Process exited because context was cancelled — client disconnected + // or server is shutting down. Expected; not worth surfacing. + // We do not gate on ExitCode()==-1: on Unix a signal-killed process + // has no exit code (-1), but on Windows TerminateProcess sets exit + // code 1. Checking ctx.Err() alone is portable across both. + log.FromContext(ctx).Debug("git process exited on context cancellation", "service", svc.Name()) + return nil + } + // When WaitDelay fires alongside a non-zero exit, cmd.Wait returns + // errors.Join(exitErr, exec.ErrWaitDelay). Strip the WaitDelay noise + // so callers see a clean exit-status error, not an internal timeout. + retErr := error(exitErr) + if len(exitErr.Stderr) > 0 { + retErr = fmt.Errorf("%w: %s", exitErr, exitErr.Stderr) + } + return retErr + } else if errors.Is(err, exec.ErrWaitDelay) { + // WaitDelay (30s) expired before pipe goroutines drained. This branch + // is only reached when there is no accompanying ExitError (i.e. git + // exited 0 but the client read the pipe slowly). The git command + // succeeded; the drain timeout is an internal bookkeeping detail, not + // a caller-visible error. + log.FromContext(ctx).Debug("git pipe drain timed out", "service", svc.Name()) + return nil } return err diff --git a/pkg/jobs/mirror.go b/pkg/jobs/mirror.go index 4642e72b1..3636b9ffa 100644 --- a/pkg/jobs/mirror.go +++ b/pkg/jobs/mirror.go @@ -69,20 +69,38 @@ func (m mirrorPull) Func(ctx context.Context) func() { "remote update --prune", // update remote and prune remote refs } + // Build the SSH env once; reused for every git command below. + sshEnv := fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, + filepath.Join(cfg.DataPath, "ssh", "known_hosts"), + cfg.SSH.ClientKeyPath, + ) + + syncOK := true for _, c := range cmds { args := strings.Split(c, " ") - cmd := git.NewCommand(args...).WithContext(ctx) - cmd.AddEnvs( - fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`, - filepath.Join(cfg.DataPath, "ssh", "known_hosts"), - cfg.SSH.ClientKeyPath, - ), - ) + // Prepend -c gc.auto=0 to disable automatic garbage + // collection. git fetch and git remote update both + // trigger gc --auto by default; on large repos this + // spawns git rev-list --objects --all and pegs a CPU + // for the entire sync window. + args = append([]string{"-c", "gc.auto=0"}, args...) + // Use no timeout (-1) so that syncing large repos is not + // killed by the 1-minute DefaultTimeout. The initial clone + // in ImportRepository already sets Timeout: -1 for the + // same reason. git-module RunInDirWithOptions only applies + // a deadline when timeout > 0, so -1 is safe here. + cmd := git.NewCommand(args...).WithContext(ctx).WithTimeout(-1) + cmd.AddEnvs(sshEnv) if _, err := cmd.RunInDir(r.Path); err != nil { - logger.Error("error running git remote update", "repo", name, "err", err) + logger.Error("error running git command", "cmd", c, "repo", name, "err", err) + syncOK = false + break } } + if !syncOK { + return + } if cfg.LFS.Enabled { rcfg, err := r.Config() diff --git a/pkg/lfs/common.go b/pkg/lfs/common.go index aa98c6527..3162f640b 100644 --- a/pkg/lfs/common.go +++ b/pkg/lfs/common.go @@ -68,7 +68,12 @@ type Link struct { Href string `json:"href"` Header map[string]string `json:"header,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` - ExpiresIn *time.Duration `json:"expires_in,omitempty"` + // ExpiresIn is the number of seconds (not nanoseconds, not milliseconds) + // until the link expires, per the git-lfs batch API spec. Callers MUST + // pass whole seconds (e.g. int64(d / time.Second)). Using int64 instead + // of time.Duration avoids serialising nanoseconds, which overflows int32 + // on 32-bit platforms. + ExpiresIn *int64 `json:"expires_in,omitempty"` } // ObjectError defines the JSON structure returned to the client in case of an error. @@ -94,10 +99,14 @@ type Reference struct { } // AuthenticateResponse is the git-lfs-authenticate JSON response object. +// ExpiresIn is encoded as seconds per the git-lfs SSH authentication spec: +// https://github.com/git-lfs/git-lfs/blob/main/docs/api/server-discovery.md +// Using int64 (not time.Duration) avoids serialising nanoseconds, which +// overflows int32 on 32-bit platforms (issue #781). type AuthenticateResponse struct { Header map[string]string `json:"header"` Href string `json:"href"` - ExpiresIn time.Duration `json:"expires_in"` + ExpiresIn int64 `json:"expires_in"` ExpiresAt time.Time `json:"expires_at"` } diff --git a/pkg/ssh/cmd/cmd.go b/pkg/ssh/cmd/cmd.go index 18624eb33..d82d4ebb9 100644 --- a/pkg/ssh/cmd/cmd.go +++ b/pkg/ssh/cmd/cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "net/url" "strings" @@ -109,6 +110,17 @@ func CommandName(args []string) string { return args[0] } +// currentUser resolves the authenticated user from the session context. +// It checks the context user first (set by AuthenticationMiddleware for +// both public key and token auth), avoiding a redundant DB lookup. +func currentUser(ctx context.Context) (proto.User, error) { + user := proto.UserFromContext(ctx) + if user != nil { + return user, nil + } + return nil, proto.ErrUserNotFound +} + func checkIfReadable(cmd *cobra.Command, args []string) error { var repo string if len(args) > 0 { diff --git a/pkg/ssh/cmd/git.go b/pkg/ssh/cmd/git.go index caf1e1595..e99e78fe3 100644 --- a/pkg/ssh/cmd/git.go +++ b/pkg/ssh/cmd/git.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "path/filepath" "strings" @@ -291,7 +292,19 @@ func gitRunE(cmd *cobra.Command, args []string) error { if errors.Is(err, git.ErrInvalidRepo) { return git.ErrInvalidRepo } else if err != nil { - logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) + // When the client disconnects early (e.g. Flux Source controller + // closes the connection after receiving ref advertisements), the + // SSH session context is cancelled and exec.CommandContext kills + // the git subprocess with SIGKILL, producing "signal: killed". + // That is normal — log it at debug level, not error. + // Use context.Canceled specifically: context.DeadlineExceeded + // means a server-side timeout fired, which is still worth an + // ERROR so operators can tune their timeout settings. + if ctx.Err() == context.Canceled { + logger.Debug("git service ended on client disconnect", "service", service, "err", err, "repo", name) + } else { + logger.Error("failed to handle git service", "service", service, "err", err, "repo", name) + } return git.ErrSystemMalfunction } diff --git a/pkg/ssh/cmd/info.go b/pkg/ssh/cmd/info.go index bb932390a..a60c2c119 100644 --- a/pkg/ssh/cmd/info.go +++ b/pkg/ssh/cmd/info.go @@ -1,7 +1,6 @@ package cmd import ( - "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) @@ -14,9 +13,7 @@ func InfoCommand() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) + user, err := currentUser(ctx) if err != nil { return err } diff --git a/pkg/ssh/cmd/list.go b/pkg/ssh/cmd/list.go index bcdfa99cd..655b0b9a8 100644 --- a/pkg/ssh/cmd/list.go +++ b/pkg/ssh/cmd/list.go @@ -3,7 +3,7 @@ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" - "github.com/charmbracelet/soft-serve/pkg/sshutils" + "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/spf13/cobra" ) @@ -19,13 +19,13 @@ func listCommand() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) + user := proto.UserFromContext(ctx) repos, err := be.Repositories(ctx) if err != nil { return err } for _, r := range repos { - if be.AccessLevelByPublicKey(ctx, r.Name(), pk) >= access.ReadOnlyAccess { + if be.AccessLevelForUser(ctx, r.Name(), user) >= access.ReadOnlyAccess { if !r.IsHidden() || all { cmd.Println(r.Name()) } diff --git a/pkg/ssh/cmd/pubkey.go b/pkg/ssh/cmd/pubkey.go index 697ab51c8..d3273ac6e 100644 --- a/pkg/ssh/cmd/pubkey.go +++ b/pkg/ssh/cmd/pubkey.go @@ -23,8 +23,7 @@ func PubkeyCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) + user, err := currentUser(ctx) if err != nil { return err } @@ -45,8 +44,7 @@ func PubkeyCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) + user, err := currentUser(ctx) if err != nil { return err } @@ -67,9 +65,7 @@ func PubkeyCommand() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) + user, err := currentUser(ctx) if err != nil { return err } diff --git a/pkg/ssh/cmd/repo.go b/pkg/ssh/cmd/repo.go index fa3e72ff9..e28fc8d80 100644 --- a/pkg/ssh/cmd/repo.go +++ b/pkg/ssh/cmd/repo.go @@ -1,9 +1,11 @@ package cmd import ( + "errors" "fmt" "strings" + "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/pkg/backend" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/spf13/cobra" @@ -58,7 +60,8 @@ func RepoCommand() *cobra.Command { } head, err := r.HEAD() - if err != nil { + isEmpty := errors.Is(err, git.ErrReferenceNotExist) + if err != nil && !isEmpty { return err } @@ -84,7 +87,11 @@ func RepoCommand() *cobra.Command { if owner != nil { cmd.Println(strings.TrimSpace(fmt.Sprint("Owner: ", owner.Username()))) } - cmd.Println("Default Branch:", head.Name().Short()) + if isEmpty { + cmd.Println("Default Branch: (empty repository)") + } else { + cmd.Println("Default Branch:", head.Name().Short()) + } if len(branches) > 0 { cmd.Println("Branches:") for _, b := range branches { diff --git a/pkg/ssh/cmd/set_username.go b/pkg/ssh/cmd/set_username.go index 1fe93616b..6a46cb17a 100644 --- a/pkg/ssh/cmd/set_username.go +++ b/pkg/ssh/cmd/set_username.go @@ -2,7 +2,6 @@ package cmd import ( "github.com/charmbracelet/soft-serve/pkg/backend" - "github.com/charmbracelet/soft-serve/pkg/sshutils" "github.com/spf13/cobra" ) @@ -15,8 +14,7 @@ func SetUsernameCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() be := backend.FromContext(ctx) - pk := sshutils.PublicKeyFromContext(ctx) - user, err := be.UserByPublicKey(ctx, pk) + user, err := currentUser(ctx) if err != nil { return err } diff --git a/pkg/ssh/cmd/user.go b/pkg/ssh/cmd/user.go index 21981f9cb..aea93ce3b 100644 --- a/pkg/ssh/cmd/user.go +++ b/pkg/ssh/cmd/user.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "sort" "strings" @@ -22,22 +23,45 @@ func UserCommand() *cobra.Command { var admin bool var key string userCreateCommand := &cobra.Command{ - Use: "create USERNAME", - Short: "Create a new user", - Args: cobra.ExactArgs(1), + Use: "create USERNAME", + Short: "Create a new user", + Long: `Create a new user. + +When passing a public key with -k, shell quoting is stripped by OpenSSH +before the command is transmitted, so an ed25519 key such as: + + ssh host user create alice -k 'ssh-ed25519 AAAA... user@host' + +arrives on the server as three separate tokens. Soft Serve re-joins them +automatically, so both of the following forms work: + + -k 'ssh-ed25519 AAAA... user@host' (quoted, local shell) + -k ssh-ed25519 AAAA... user@host (unquoted, same effect over SSH) +`, + Args: cobra.MinimumNArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { var pubkeys []ssh.PublicKey ctx := cmd.Context() be := backend.FromContext(ctx) username := args[0] - if key != "" { - pk, _, err := sshutils.ParseAuthorizedKey(key) + + switch { + case cmd.Flags().Changed("key") && key == "": + // -k was supplied with an empty value. + return fmt.Errorf("flag --key requires a non-empty public key") + case cmd.Flags().Changed("key"): + // Re-join the -k value with any remaining positional args. + // This reconstructs a key split across tokens by SSH quoting + // stripping (e.g. 'ssh-ed25519 AAAA' → two tokens). + keyStr := strings.TrimSpace(strings.Join(append([]string{key}, args[1:]...), " ")) + pk, _, err := sshutils.ParseAuthorizedKey(keyStr) if err != nil { return err } - pubkeys = []ssh.PublicKey{pk} + case len(args) > 1: + return fmt.Errorf("unexpected arguments: %s", strings.Join(args[1:], " ")) } opts := proto.UserOptions{ diff --git a/pkg/ssh/cmd/user_test.go b/pkg/ssh/cmd/user_test.go new file mode 100644 index 000000000..a58f7d357 --- /dev/null +++ b/pkg/ssh/cmd/user_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/charmbracelet/soft-serve/pkg/sshutils" +) + +// reconstructKey mirrors the logic in userCreateCommand.RunE: +// join the -k flag value with any trailing positional args and parse. +func reconstructKey(flagValue string, extraArgs []string) (string, error) { + keyStr := strings.TrimSpace(strings.Join(append([]string{flagValue}, extraArgs...), " ")) + _, _, err := sshutils.ParseAuthorizedKey(keyStr) + return keyStr, err +} + +// TestUserCreateKeyReconstruction verifies the key-joining logic used when +// OpenSSH strips shell quoting and delivers the key as separate tokens. +// +// Note: the empty-key guard (cmd.Flags().Changed("key") && key == "") +// is exercised only via integration tests (testscript/testdata) because +// invoking RunE requires passing the checkIfAdmin PersistentPreRunE, +// which needs a full backend context. +func TestUserCreateKeyReconstruction(t *testing.T) { + // Generate a real ed25519 key in authorized_keys format for tests. + const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBzKEBMH+cKg8+8v7CJrbPBpbmMHbzSENKgmHhYRhM89 test@host" + keyType := "ssh-ed25519" + keyBody := "AAAAC3NzaC1lZDI1NTE5AAAAIBzKEBMH+cKg8+8v7CJrbPBpbmMHbzSENKgmHhYRhM89" + comment := "test@host" + + tests := []struct { + name string + flagValue string // value cobra captures for -k + extraArgs []string + wantErr bool + wantKey string // expected reconstructed key string + }{ + { + name: "full key in flag value (proper quoting preserved)", + flagValue: testKey, + extraArgs: nil, + wantKey: testKey, + }, + { + name: "key type in flag, body as extra arg (SSH quoting stripped)", + flagValue: keyType, + extraArgs: []string{keyBody}, + wantKey: keyType + " " + keyBody, + }, + { + name: "key type in flag, body and comment as extra args", + flagValue: keyType, + extraArgs: []string{keyBody, comment}, + wantKey: keyType + " " + keyBody + " " + comment, + }, + { + name: "key type only (missing body) returns parse error", + flagValue: keyType, + extraArgs: nil, + wantErr: true, + }, + { + name: "empty flag value returns parse error", + flagValue: "", + extraArgs: nil, + wantErr: true, + }, + { + name: "invalid base64 body returns parse error", + flagValue: keyType, + extraArgs: []string{"!!!not-base64!!!"}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := reconstructKey(tc.flagValue, tc.extraArgs) + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got key %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.wantKey { + t.Errorf("reconstructed key = %q; want %q", got, tc.wantKey) + } + }) + } +} diff --git a/pkg/ssh/middleware.go b/pkg/ssh/middleware.go index 25a00b1a2..2163055d6 100644 --- a/pkg/ssh/middleware.go +++ b/pkg/ssh/middleware.go @@ -55,17 +55,37 @@ func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler { return } + // Check if this session was authenticated via access token. + // Token-authenticated sessions carry the user ID via a package-private + // context key (tokenAuthUserIDKey) set during keyboard-interactive auth. + // We intentionally do NOT read this from perms.Extensions — certificate + // extensions from gossh are merged into the same map, so a string key + // can be injected by a client presenting a crafted certificate. + _, isTokenAuth := ctx.Value(tokenAuthUserIDKey{}).(int64) + ac := be.AllowKeyless(ctx) - publicKeyCounter.WithLabelValues(strconv.FormatBool(ac || pk != nil)).Inc() - if !ac && pk == nil { + publicKeyCounter.WithLabelValues(strconv.FormatBool(ac || pk != nil || isTokenAuth)).Inc() + if !ac && pk == nil && !isTokenAuth { wish.Fatalln(s, ErrPermissionDenied) return } - // Set the auth'd user, or anon, in the context + // Set the auth'd user, or anon, in the context. var user proto.User if pk != nil { - user, _ = be.UserByPublicKey(ctx, pk) + if u, err := be.UserByPublicKey(ctx, pk); err != nil { + log.FromContext(ctx).Debug("public key lookup failed", "err", err) + } else { + user = u + } + } else if tokenID, ok := ctx.Value(tokenAuthUserIDKey{}).(int64); ok { + if u, err := be.UserByID(ctx, tokenID); err != nil { + log.FromContext(ctx).Warn("failed to resolve token-auth user", "id", tokenID, "err", err) + wish.Fatalln(s, ErrPermissionDenied) + return + } else { + user = u + } } ctx.SetValue(proto.ContextKeyUser, user) diff --git a/pkg/ssh/session.go b/pkg/ssh/session.go index 295542d5e..3cb8dcca3 100644 --- a/pkg/ssh/session.go +++ b/pkg/ssh/session.go @@ -47,7 +47,7 @@ func SessionHandler(s ssh.Session) *tea.Program { initialRepo = cmd[0] } - auth := be.AccessLevelByPublicKey(ctx, initialRepo, s.PublicKey()) + auth := be.AccessLevelForUser(ctx, initialRepo, proto.UserFromContext(ctx)) if auth < access.ReadOnlyAccess { wish.Fatalln(s, proto.ErrUnauthorized) return nil diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index 6fc9fcdf8..6daa63855 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -39,6 +39,12 @@ var ( }, []string{"allowed"}) ) +// tokenAuthUserIDKey is a package-private context key used to carry the +// token-authenticated user ID from KeyboardInteractiveHandler to +// AuthenticationMiddleware. Using a private type (not a string) prevents +// injection via SSH certificate extensions, which use string-keyed maps. +type tokenAuthUserIDKey struct{} + // SSHServer is a SSH server that implements the git protocol. type SSHServer struct { //nolint: revive srv *ssh.Server @@ -157,6 +163,7 @@ func initializePermissions(ctx ssh.Context) { if perms.Extensions == nil { perms.Extensions = make(map[string]string) } + ctx.SetValue(ssh.ContextKeyPermissions, perms) } // PublicKeyHandler handles public key authentication. @@ -180,20 +187,47 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed } // KeyboardInteractiveHandler handles keyboard interactive authentication. -// This is used after all public key authentication has failed. -func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { - ac := s.be.AllowKeyless(ctx) - keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc() - - // If we're allowing keyless access, reset the public key fingerprint +// It prompts for an access token and validates it. If no valid token is +// provided, it falls back to AllowKeyless behavior. +func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, challenge gossh.KeyboardInteractiveChallenge) bool { initializePermissions(ctx) perms := ctx.Permissions() + // Prompt the user for an access token. + answers, err := challenge("", "", []string{"Access Token: "}, []bool{false}) + if err != nil { + s.logger.Debug("keyboard-interactive challenge failed", "err", err) + } else if len(answers) > 0 && answers[0] != "" { + token := answers[0] + user, tokenErr := s.be.UserByAccessToken(ctx, token) + if tokenErr == nil && user != nil { + // Valid token: store the user ID via a package-private context key. + // We intentionally do NOT use perms.Extensions here — certificate + // extensions from gossh are merged into the same map, so a string + // key can be injected by a client presenting a crafted certificate. + // + // Clear pubkey-fp so AuthenticationMiddleware's fingerprint guard + // does not reject this keyless token-auth session. + perms.Extensions["pubkey-fp"] = "" + ctx.SetValue(ssh.ContextKeyPermissions, perms) + ctx.SetValue(tokenAuthUserIDKey{}, user.ID()) + keyboardInteractiveCounter.WithLabelValues("true").Inc() + s.logger.Info("keyboard-interactive token auth succeeded", "username", user.Username()) + return true + } + if tokenErr != nil { + s.logger.Warn("keyboard-interactive token auth failed", "err", tokenErr) + } else { + s.logger.Warn("keyboard-interactive token auth failed", "err", "user not found") + } + } + + // No valid token: fall back to AllowKeyless behavior. + ac := s.be.AllowKeyless(ctx) + keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc() + if ac { - // XXX: reset the public-key fingerprint. This is used to validate the - // public key being used to authenticate. perms.Extensions["pubkey-fp"] = "" - ctx.SetValue(ssh.ContextKeyPermissions, perms) } return ac } diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go index 0ee72963e..d6cd61edf 100644 --- a/pkg/stats/stats.go +++ b/pkg/stats/stats.go @@ -2,6 +2,7 @@ package stats import ( "context" + "net" "net/http" "time" @@ -35,6 +36,13 @@ func NewStatsServer(ctx context.Context) (*StatsServer, error) { }, nil } +// Serve accepts connections on l and serves HTTP requests. +// The stats endpoint intentionally does not support TLS; it is expected +// to be exposed only on a loopback or internal network interface. +func (s *StatsServer) Serve(l net.Listener) error { + return s.server.Serve(l) +} + // ListenAndServe starts the StatsServer. func (s *StatsServer) ListenAndServe() error { return s.server.ListenAndServe() diff --git a/pkg/ui/pages/repo/files.go b/pkg/ui/pages/repo/files.go index 4c51e3561..77926e2e9 100644 --- a/pkg/ui/pages/repo/files.go +++ b/pkg/ui/pages/repo/files.go @@ -3,7 +3,7 @@ package repo import ( "errors" "fmt" - "path/filepath" + "path" "strings" "charm.land/bubbles/v2/key" @@ -253,7 +253,7 @@ func (f *Files) Update(msg tea.Msg) (common.Model, tea.Cmd) { switch sel := msg.IdentifiableItem.(type) { case FileItem: f.currentItem = &sel - f.path = filepath.Join(f.path, sel.entry.Name()) + f.path = path.Join(f.path, sel.entry.Name()) if sel.entry.IsTree() { cmds = append(cmds, f.selectTreeCmd) } else { @@ -458,19 +458,19 @@ func (f *Files) selectFileCmd() tea.Msg { if !bin { bin, err = fi.IsBinary() if err != nil { - f.path = filepath.Dir(f.path) + f.path = path.Dir(f.path) return common.ErrorMsg(err) } } if bin { - f.path = filepath.Dir(f.path) + f.path = path.Dir(f.path) return common.ErrorMsg(errBinaryFile) } c, err := fi.Bytes() if err != nil { - f.path = filepath.Dir(f.path) + f.path = path.Dir(f.path) return common.ErrorMsg(err) } @@ -527,7 +527,7 @@ func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string { } func (f *Files) deselectItemCmd() tea.Cmd { - f.path = filepath.Dir(f.path) + f.path = path.Dir(f.path) index := 0 if len(f.lastSelected) > 0 { index = f.lastSelected[len(f.lastSelected)-1] diff --git a/pkg/ui/pages/repo/readme.go b/pkg/ui/pages/repo/readme.go index e7f1b9087..4d7452dd4 100644 --- a/pkg/ui/pages/repo/readme.go +++ b/pkg/ui/pages/repo/readme.go @@ -1,7 +1,7 @@ package repo import ( - "path/filepath" + "path" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" @@ -106,6 +106,7 @@ func (r *Readme) Update(msg tea.Msg) (common.Model, tea.Cmd) { case tea.WindowSizeMsg: r.SetSize(msg.Width, msg.Height) case EmptyRepoMsg: + r.isLoading = false cmds = append(cmds, r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(), r.repo.Name()), ".md"), @@ -147,7 +148,7 @@ func (r *Readme) SpinnerID() int { // StatusBarValue implements statusbar.StatusBar. func (r *Readme) StatusBarValue() string { - dir := filepath.Dir(r.readmePath) + dir := path.Dir(r.readmePath) if dir == "." || dir == "" { return " " } @@ -164,7 +165,10 @@ func (r *Readme) updateReadmeCmd() tea.Msg { if r.repo == nil { return common.ErrorMsg(common.ErrMissingRepo) } - rm, rp, _ := backend.Readme(r.repo, r.ref) + rm, rp, err := backend.Readme(r.repo, r.ref) + if err != nil { + return common.ErrorMsg(err) + } m.Content = rm m.Path = rp return m diff --git a/pkg/ui/pages/repo/repo.go b/pkg/ui/pages/repo/repo.go index 58052bd57..369429558 100644 --- a/pkg/ui/pages/repo/repo.go +++ b/pkg/ui/pages/repo/repo.go @@ -115,8 +115,11 @@ func (r *Repo) commonHelp() []key.Binding { back.SetHelp("esc", "back to menu") tab := r.common.KeyMap.Section tab.SetHelp("tab", "switch tab") + copyKey := r.common.KeyMap.Copy + copyKey.SetHelp("c", "copy clone cmd") b = append(b, back) b = append(b, tab) + b = append(b, copyKey) return b } @@ -204,6 +207,15 @@ func (r *Repo) Update(msg tea.Msg) (common.Model, tea.Cmd) { switch { case key.Matches(msg, r.common.KeyMap.Back): cmds = append(cmds, goBackCmd) + case key.Matches(msg, r.common.KeyMap.Copy): + // Only handle clone copy on the Readme tab to avoid + // conflicting with pane-level copy handlers (Files, Log, Refs, Stash). + if r.selectedRepo != nil && r.panes[r.activeTab].TabName() == "Readme" { + cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name()) + if cmd != "" { + cmds = append(cmds, copyCmd(cmd, "Clone command copied to clipboard")) + } + } } } case CopyMsg: diff --git a/pkg/ui/pages/selection/item.go b/pkg/ui/pages/selection/item.go index b6b3a02fa..991582c0e 100644 --- a/pkg/ui/pages/selection/item.go +++ b/pkg/ui/pages/selection/item.go @@ -18,6 +18,9 @@ import ( var _ sort.Interface = Items{} +// copiedResetMsg is sent after the copy feedback timer expires. +type copiedResetMsg struct{ gen int } + // Items is a list of Item. type Items []Item @@ -98,9 +101,11 @@ func (i Item) Command() string { // ItemDelegate is the delegate for the item. type ItemDelegate struct { - common *common.Common - activePane *pane - copiedIdx int + common *common.Common + activePane *pane + copiedItem Item + hasCopied bool + copiedGen int } // NewItemDelegate creates a new ItemDelegate. @@ -108,7 +113,6 @@ func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate { return &ItemDelegate{ common: common, activePane: activePane, - copiedIdx: -1, } } @@ -129,7 +133,6 @@ func (d *ItemDelegate) Spacing() int { return 1 } // Update implements list.ItemDelegate. func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - idx := m.Index() item, ok := m.SelectedItem().(Item) if !ok { return nil @@ -138,12 +141,23 @@ func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { case tea.KeyPressMsg: switch { case key.Matches(msg, d.common.KeyMap.Copy): - d.copiedIdx = idx + d.copiedItem = item + d.hasCopied = true + d.copiedGen++ + gen := d.copiedGen return tea.Batch( tea.SetClipboard(item.Command()), - m.SetItem(idx, item), + func() tea.Msg { + time.Sleep(1500 * time.Millisecond) + return copiedResetMsg{gen: gen} + }, ) } + case copiedResetMsg: + if msg.gen == d.copiedGen { + d.hasCopied = false + d.copiedItem = Item{} + } } return nil } @@ -207,10 +221,9 @@ func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem lis cmd := i.Command() cmdStyler := styles.Command.Render - if d.copiedIdx == index { + if d.hasCopied && d.copiedItem.ID() == i.ID() { cmd = "(copied to clipboard)" cmdStyler = styles.Desc.Render - d.copiedIdx = -1 } cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize()) s.WriteString(cmdStyler(cmd)) diff --git a/pkg/ui/pages/selection/selection.go b/pkg/ui/pages/selection/selection.go index 270cf2bb7..983c5398d 100644 --- a/pkg/ui/pages/selection/selection.go +++ b/pkg/ui/pages/selection/selection.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/soft-serve/pkg/access" "github.com/charmbracelet/soft-serve/pkg/backend" + "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/ui/common" "github.com/charmbracelet/soft-serve/pkg/ui/components/code" "github.com/charmbracelet/soft-serve/pkg/ui/components/selector" @@ -191,7 +192,8 @@ func (s *Selection) Init() tea.Cmd { ctx := s.common.Context() be := s.common.Backend() pk := s.common.PublicKey() - if pk == nil && !be.AllowKeyless(ctx) { + user := proto.UserFromContext(ctx) + if pk == nil && user == nil && !be.AllowKeyless(ctx) { return nil } @@ -203,7 +205,7 @@ func (s *Selection) Init() tea.Cmd { for _, r := range repos { if r.Name() == ".soft-serve" { readme, path, err := backend.Readme(r, nil) - if err != nil { + if err != nil || path == "" { continue } @@ -213,7 +215,7 @@ func (s *Selection) Init() tea.Cmd { if r.IsHidden() { continue } - al := be.AccessLevelByPublicKey(ctx, r.Name(), pk) + al := be.AccessLevelForUser(ctx, r.Name(), proto.UserFromContext(ctx)) if al >= access.ReadOnlyAccess { item, err := NewItem(s.common, r) if err != nil { diff --git a/pkg/web/blob.go b/pkg/web/blob.go new file mode 100644 index 000000000..f1a5e9326 --- /dev/null +++ b/pkg/web/blob.go @@ -0,0 +1,184 @@ +package web + +import ( + "bytes" + "mime" + "net/http" + "path/filepath" + "strconv" + "strings" + + gitb "github.com/charmbracelet/soft-serve/git" + "github.com/gorilla/mux" +) + +// maxRawBlobSize is the largest blob that getRawBlob will read into memory. +// Requests for blobs exceeding this size receive HTTP 413. +const maxRawBlobSize = 32 * 1024 * 1024 // 32 MiB + +// getRawBlob serves the raw content of a single file at a given ref and path. +// It is registered as GET /{repo}/raw/{ref}/{filepath}. +// +// Access control is enforced by the withAccess middleware that wraps this +// handler: unauthenticated users see 401, insufficient-access users see 404 +// (to avoid leaking repo existence). The handler never re-checks access. +// +// The Accept header controls delivery: +// - "application/octet-stream" → Content-Disposition: attachment (download) +// - anything else → Content-Type inferred from extension or +// binary detection (text/plain for text, application/octet-stream for binary) +// +// Note: dir is constructed and sanitised by the withParams middleware and must +// not be derived locally inside this handler. +func getRawBlob(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + dir := vars["dir"] + ref := vars["ref"] + filePath := vars["filepath"] + + if filePath == "" { + renderBadRequest(w, r) + return + } + + repo, err := gitb.Open(dir) + if err != nil { + renderNotFound(w, r) + return + } + + // Resolve HEAD when no ref is given. + if ref == "" || ref == "HEAD" { + head, err := repo.HEAD() + if err != nil { + renderNotFound(w, r) + return + } + ref = head.ID + } + + tree, err := repo.LsTree(ref) + if err != nil { + renderNotFound(w, r) + return + } + + te, err := tree.TreeEntry(filePath) + if err != nil { + renderNotFound(w, r) + return + } + + // Must be a blob (file), not a tree (directory). + if te.Type() != "blob" { + renderNotFound(w, r) + return + } + + // Guard against OOM/DoS from very large blobs. + // te.Size() calls `git cat-file -s` and silently returns 0 on error, + // so this is a fast early-out only; the post-load check below is the + // authoritative guard. + if te.Size() > maxRawBlobSize { + renderStatus(http.StatusRequestEntityTooLarge)(w, r) + return + } + + bts, err := te.Contents() + if err != nil { + renderInternalServerError(w, r) + return + } + + // Belt-and-suspenders: re-check after loading in case te.Size() returned + // 0 due to a silent git subprocess error. + if int64(len(bts)) > maxRawBlobSize { + renderStatus(http.StatusRequestEntityTooLarge)(w, r) + return + } + + // Determine Content-Type from extension first, then fall back to binary + // detection using the bytes already in memory (avoids a second git subprocess). + contentType := mime.TypeByExtension(filepath.Ext(filePath)) + if contentType == "" { + isBin, _ := gitb.IsBinary(bytes.NewReader(bts)) + if isBin { + contentType = "application/octet-stream" + } else { + contentType = "text/plain; charset=utf-8" + } + } + + // Sanitise: downgrade any MIME type that a browser will execute scripts from. + // This prevents stored-XSS when an attacker pushes an .html/.svg/.js file. + contentType = sanitizeMIME(contentType) + + // X-Content-Type-Options prevents browsers from sniffing and upgrading the type. + w.Header().Set("X-Content-Type-Options", "nosniff") + + // If the client explicitly requests a binary stream, serve as download. + // NOTE: sanitizeMIME has already run above. Any content-type set in this + // block must itself be safe to serve without further sanitisation. + if r.Header.Get("Accept") == "application/octet-stream" { + contentType = "application/octet-stream" + // Build a safe filename for Content-Disposition per RFC 6266 §4.3: + // - strip ASCII control characters (0x00-0x1F, 0x7F) to prevent + // response-header injection via crafted filenames + // - escape embedded double-quotes + rawName := filepath.Base(filePath) + safeName := strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7F { + return -1 // drop control character + } + return r + }, rawName) + safeName = strings.ReplaceAll(safeName, `"`, `\"`) + w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`) + } + + // Mutable refs (branch names, tags) must not be cached by proxies. + // A future improvement could set max-age for immutable SHA refs. + w.Header().Set("Cache-Control", "no-store") + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(bts)), 10)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(bts) +} + +// sanitizeMIME uses an allowlist to ensure only MIME types that cannot execute +// scripts in a browser are forwarded. Everything else is downgraded to +// text/plain to prevent stored-XSS from pushed .html/.svg/.js/.css/etc. files. +// +// Allowlisted categories: +// - text/plain, application/octet-stream (safe by definition) +// - application/json (data only, not rendered/executed by browsers) +// - image/* except SVG (SVG allows embedded scripts) +// - audio/*, video/* (media; cannot execute scripts) +// +// All other types — including text/html, text/css, *+xml, */javascript, +// application/pdf, font/*, multipart/* — are downgraded. +func sanitizeMIME(ct string) string { + // Strip parameters for comparison (e.g. "text/html; charset=utf-8" → "text/html"). + base := ct + if i := strings.Index(ct, ";"); i != -1 { + base = strings.TrimSpace(ct[:i]) + } + base = strings.ToLower(base) + + switch { + case base == "text/plain", + base == "application/octet-stream", + base == "application/json": + return ct + case strings.HasPrefix(base, "image/") && base != "image/svg+xml": + // SVG is excluded: it supports embedded