diff --git a/AGENTS.md b/AGENTS.md index ed79741..83b8dc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ Notes on the tests and on configuration: | `cmd/objgitd/git_protocol.go` | The git:// server. Also holds `operationFor` and `(*daemon).authorize`. | | `cmd/objgitd/http.go` | Smart HTTP. `*daemon` is the `http.Handler` itself. | | `cmd/objgitd/ssh.go` | The SSH server and its per-session dispatch. | +| `cmd/objgitd/shell.go` | The SSH `sh` command: an interactive shell in the hook sandbox. | | `cmd/objgitd/receivepack.go` | The go-git fork that streams hook output, plus `writePack`. | | `cmd/objgitd/hooks.go` | Ref diffing and the sandboxed hook run. | | `internal/auth` | The one authorization interface. | diff --git a/cmd/objgitd/hooks.go b/cmd/objgitd/hooks.go index 3245630..31d23a3 100644 --- a/cmd/objgitd/hooks.go +++ b/cmd/objgitd/hooks.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "log/slog" "strings" @@ -12,6 +13,7 @@ import ( "github.com/go-git/go-billy/v6" "github.com/go-git/go-billy/v6/memfs" + "github.com/go-git/go-billy/v6/util" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" "github.com/go-git/go-git/v6/plumbing/transport" @@ -29,6 +31,11 @@ import ( "tangled.org/xeiaso.net/kefka/command/registry/coreutils" ) +// receivePackHook names the hook that a push runs, and so its script at +// .objgit/hooks/receive-pack and its OBJGIT_SERVICE. It is not the transport +// service name, which is "git-receive-pack". +const receivePackHook = "receive-pack" + // refUpdate records a single branch ref change applied by a receive-pack. // A zero Old means the branch was created; a zero New means it was deleted. type refUpdate struct { @@ -67,7 +74,7 @@ func (d *daemon) receivePack(ctx context.Context, st storage.Storer, repoPath st branches = append(branches, u) } } - d.runHooks(repoPath, "receive-pack", st, branches, progress) + d.runHooks(repoPath, receivePackHook, st, branches, progress) } d.emitPushWebhooks(ctx, st, repoPath, webhooks, updates, acceptedAt) } @@ -132,59 +139,13 @@ func (d *daemon) runHook(repoPath, service string, st storage.Storer, u refUpdat return } - fsys := mountfs.New(map[string]billy.Filesystem{ - "src": treefs.New(tree), - "tmp": memfs.New(), - }) - ctx, cancel := context.WithTimeout(context.Background(), d.hookTimeout) defer cancel() - changes, err := pushevents.Diff(ctx, st, u.Old, u.New) + changes, err := loadHookChanges(ctx, st, u) if err != nil { log.Error("hook: diff changed files", "err", err) return } - // Keep empty lists as JSON arrays. Paths can contain whitespace or newlines, - // so JSON is the only unambiguous representation in environment variables. - added := append([]string{}, changes.GetAdded()...) - changed := append([]string{}, changes.GetChanged()...) - deleted := append([]string{}, changes.GetDeleted()...) - addedJSON, err := json.Marshal(added) - if err != nil { - log.Error("hook: encode added files", "err", err) - return - } - changedJSON, err := json.Marshal(changed) - if err != nil { - log.Error("hook: encode changed files", "err", err) - return - } - deletedJSON, err := json.Marshal(deleted) - if err != nil { - log.Error("hook: encode deleted files", "err", err) - return - } - metadata, err := json.Marshal(struct { - Added []string `json:"added"` - Changed []string `json:"changed"` - Deleted []string `json:"deleted"` - }{added, changed, deleted}) - if err != nil { - log.Error("hook: encode changed files", "err", err) - return - } - const changesFile = "/tmp/objgit-changes.json" - f, err := fsys.Create(changesFile) - if err != nil { - log.Error("hook: create changes file", "err", err) - return - } - _, writeErr := io.Copy(f, bytes.NewReader(metadata)) - closeErr := f.Close() - if err := errors.Join(writeErr, closeErr); err != nil { - log.Error("hook: write changes file", "err", err) - return - } // When the client negotiated sideband, stream stdout+stderr straight to it // ("remote: " lines); otherwise buffer for the log. git does not distinguish @@ -196,49 +157,8 @@ func (d *daemon) runHook(repoPath, service string, st storage.Storer, u refUpdat stdout, stderr = progress, progress } - reg := registry.New() - coreutils.Register(reg) - if err := reg.Chdir(fsys, "/src"); err != nil { - log.Error("hook: chdir /src", "err", err) - return - } - - env := expand.ListEnviron( - "HOME=/tmp", - "PWD=/src", - "TMPDIR=/tmp", - "IFS= \t\n", - "PATH=/usr/bin:/bin", - "KEFKA=1", - "OBJGIT_REPO="+repoPath, - "OBJGIT_SERVICE="+service, - "OBJGIT_REF="+u.Name.String(), - "OBJGIT_BRANCH="+u.Name.Short(), - "OBJGIT_OLD_SHA="+u.Old.String(), - "OBJGIT_NEW_SHA="+u.New.String(), - "OBJGIT_ADDED_FILES_JSON="+string(addedJSON), - "OBJGIT_CHANGED_FILES_JSON="+string(changedJSON), - "OBJGIT_DELETED_FILES_JSON="+string(deletedJSON), - "OBJGIT_CHANGES_FILE="+changesFile, - ) - // Mirror git's post-receive stdin: " \n". - stdin := strings.NewReader(u.Old.String() + " " + u.New.String() + " " + u.Name.String() + "\n") - - var sh *interp.Runner - middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { - return func(ctx context.Context, args []string) error { - return reg.Exec(ctx, fsys, sh, args) - } - } - sh, err = interp.New( - interp.Env(env), - interp.StdIO(stdin, stdout, stderr), - interp.ExecHandlers(middleware), - interp.CallHandler(kefkash.CallHandler(reg, fsys, stdout, stderr)), - interp.StatHandler(kefkash.FsysStatHandler(reg, fsys)), - interp.OpenHandler(kefkash.FsysOpenHandler(reg, fsys)), - interp.ReadDirHandler2(kefkash.FsysReadDirHandler(reg, fsys)), - ) + stdin := strings.NewReader(hookStdin(u)) + sh, err := newHookShell(tree, changes, hookEnv(repoPath, service, u, changes), stdin, stdout, stderr) if err != nil { log.Error("hook: build shell", "err", err) return @@ -272,6 +192,123 @@ func (d *daemon) runHook(repoPath, service string, st storage.Storer, u refUpdat log.Info("hook: finished", attrs...) } +// hookChangesFile is where a hook finds the JSON object of its file changes. +const hookChangesFile = "/tmp/objgit-changes.json" + +// hookChanges holds the net file changes of one update, encoded the way a hook +// reads them. Paths can contain whitespace or newlines, so JSON is the only +// unambiguous form in environment variables. +type hookChanges struct { + added, changed, deleted []byte // JSON arrays, for the environment + file []byte // JSON object, for hookChangesFile +} + +// loadHookChanges diffs u.Old against u.New. A zero Old diffs against the +// empty tree, so every file of a new branch is added. +func loadHookChanges(ctx context.Context, st storage.Storer, u refUpdate) (hookChanges, error) { + changes, err := pushevents.Diff(ctx, st, u.Old, u.New) + if err != nil { + return hookChanges{}, err + } + // Keep empty lists as JSON arrays, not null. + lists := struct { + Added []string `json:"added"` + Changed []string `json:"changed"` + Deleted []string `json:"deleted"` + }{ + append([]string{}, changes.GetAdded()...), + append([]string{}, changes.GetChanged()...), + append([]string{}, changes.GetDeleted()...), + } + var c hookChanges + for _, enc := range []struct { + dst *[]byte + v any + }{ + {&c.added, lists.Added}, + {&c.changed, lists.Changed}, + {&c.deleted, lists.Deleted}, + {&c.file, lists}, + } { + if *enc.dst, err = json.Marshal(enc.v); err != nil { + return hookChanges{}, fmt.Errorf("encode changed files: %w", err) + } + } + return c, nil +} + +// hookEnv returns the environment a hook sees for update u, as KEY=value +// pairs. +func hookEnv(repoPath, service string, u refUpdate, c hookChanges) []string { + return []string{ + "HOME=/tmp", + "PWD=/src", + "TMPDIR=/tmp", + "IFS= \t\n", + "PATH=/usr/bin:/bin", + "KEFKA=1", + "OBJGIT_REPO=" + repoPath, + "OBJGIT_SERVICE=" + service, + "OBJGIT_REF=" + u.Name.String(), + "OBJGIT_BRANCH=" + u.Name.Short(), + "OBJGIT_OLD_SHA=" + u.Old.String(), + "OBJGIT_NEW_SHA=" + u.New.String(), + "OBJGIT_ADDED_FILES_JSON=" + string(c.added), + "OBJGIT_CHANGED_FILES_JSON=" + string(c.changed), + "OBJGIT_DELETED_FILES_JSON=" + string(c.deleted), + "OBJGIT_CHANGES_FILE=" + hookChangesFile, + } +} + +// hookStdin mirrors git's post-receive stdin for update u: " \n". +func hookStdin(u refUpdate) string { + return u.Old.String() + " " + u.New.String() + " " + u.Name.String() + "\n" +} + +// newHookShell builds the kefka sandbox a hook runs in: /src is a lazy +// read-only view of tree, /tmp is writable scratch that holds hookChangesFile, +// and the shell starts in /src with env. Both push hooks and the SSH sh command +// use it, so the two environments cannot drift apart. +func newHookShell(tree *object.Tree, changes hookChanges, env []string, stdin io.Reader, stdout, stderr io.Writer) (*interp.Runner, error) { + fsys := mountfs.New(map[string]billy.Filesystem{ + "src": treefs.New(tree), + "tmp": memfs.New(), + }) + if err := util.WriteFile(fsys, hookChangesFile, changes.file, 0o644); err != nil { + return nil, fmt.Errorf("write changes file: %w", err) + } + + reg := registry.New() + coreutils.Register(reg) + if err := reg.Chdir(fsys, "/src"); err != nil { + return nil, fmt.Errorf("chdir /src: %w", err) + } + + var sh *interp.Runner + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { + return func(ctx context.Context, args []string) error { + return reg.Exec(ctx, fsys, sh, args) + } + } + sh, err := interp.New( + interp.Env(expand.ListEnviron(env...)), + interp.StdIO(stdin, stdout, stderr), + interp.ExecHandlers(middleware), + interp.CallHandler(kefkash.CallHandler(reg, fsys, stdout, stderr)), + interp.StatHandler(kefkash.FsysStatHandler(reg, fsys)), + interp.OpenHandler(kefkash.FsysOpenHandler(reg, fsys)), + interp.ReadDirHandler2(kefkash.FsysReadDirHandler(reg, fsys)), + ) + if err != nil { + return nil, err + } + // interp seeds $PWD from Dir, which defaults to the daemon's host working + // directory. interp.Dir would stat the host, so set the field directly. The + // handlers above resolve paths through reg, so nothing else reads Dir. + sh.Dir = "/src" + return sh, nil +} + // hookStatus classifies a hook run for metrics: "timeout" when the hook's // deadline fired, "error" for any other failure (including a non-zero exit), and // "ok" otherwise. diff --git a/cmd/objgitd/hooks_test.go b/cmd/objgitd/hooks_test.go index 83f479f..f9ffb4d 100644 --- a/cmd/objgitd/hooks_test.go +++ b/cmd/objgitd/hooks_test.go @@ -76,11 +76,12 @@ func TestReceivePackHook(t *testing.T) { runGit(t, work, "config", "user.email", "test@example.com") runGit(t, work, "config", "user.name", "Test") - // The hook reads /src (cwd), writes scratch to /tmp, then attempts a write - // into the read-only /src. The final write aborts the shell with a + // The hook reads /src (cwd, and $PWD), writes scratch to /tmp, then attempts + // a write into the read-only /src. The final write aborts the shell with a // read-only error, so WROTE_SRC must never print. hook := strings.Join([]string{ "cat README.md", + `echo "cwd=$PWD"`, "echo built > /tmp/out", "cat /tmp/out", "echo nope > /src/nope.txt", @@ -101,7 +102,7 @@ func TestReceivePackHook(t *testing.T) { t.Fatalf("hook did not run; logs:\n%s", logs) } // /src is readable and /tmp is writable; their output streamed to the client. - for _, want := range []string{"hello from repo", "built"} { + for _, want := range []string{"hello from repo", "cwd=/src", "built"} { if !strings.Contains(pushOut, want) { t.Errorf("push output missing streamed hook output %q; output:\n%s", want, pushOut) } diff --git a/cmd/objgitd/shell.go b/cmd/objgitd/shell.go new file mode 100644 index 0000000..cf2dc28 --- /dev/null +++ b/cmd/objgitd/shell.go @@ -0,0 +1,348 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + ssh "github.com/gliderlabs/ssh" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/plumbing/transport" + "github.com/go-git/go-git/v6/storage" + "github.com/tigrisdata/objgit/internal/auth" + "github.com/tigrisdata/objgit/internal/metrics" + "github.com/tigrisdata/objgit/internal/repofs" + "golang.org/x/term" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +const shellUsage = "usage: sh [branch]" + +// errShellInterrupt is what shellLines returns when Ctrl-C is pressed at the +// prompt, so the parser drops the pending input. +var errShellInterrupt = errors.New("interrupt") + +// shellTarget resolves the commit an sh session inspects: the tip of branch, or +// of HEAD's branch when branch is empty. The returned update describes that +// commit as if it had just been pushed: Old is its first parent, or the zero +// hash for a root commit. +func shellTarget(st storage.Storer, branch string) (refUpdate, *object.Tree, error) { + name := plumbing.NewBranchReferenceName(branch) + if branch == "" { + head, err := st.Reference(plumbing.HEAD) + if err != nil { + return refUpdate{}, nil, fmt.Errorf("reading HEAD: %w", err) + } + if head.Type() != plumbing.SymbolicReference || !head.Target().IsBranch() { + return refUpdate{}, nil, errors.New("HEAD is detached; name a branch: " + shellUsage) + } + name = head.Target() + } + + ref, err := st.Reference(name) + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return refUpdate{}, nil, fmt.Errorf("branch %q not found", name.Short()) + } + if err != nil { + return refUpdate{}, nil, fmt.Errorf("reading %s: %w", name, err) + } + + commit, err := object.GetCommit(st, ref.Hash()) + if err != nil { + return refUpdate{}, nil, fmt.Errorf("loading commit %s: %w", ref.Hash(), err) + } + tree, err := commit.Tree() + if err != nil { + return refUpdate{}, nil, fmt.Errorf("loading tree of %s: %w", commit.Hash, err) + } + + u := refUpdate{Name: name, New: commit.Hash} + if len(commit.ParentHashes) > 0 { + u.Old = commit.ParentHashes[0] + } + return u, tree, nil +} + +// handleShell services "sh [branch]": an interactive kefka shell with +// the sandbox and environment a receive-pack hook gets, filled in from the +// branch's tip commit. It needs -allow-hooks, a PTY, and write access to the +// repository, because only a pusher can trigger a hook. +func (d *daemon) handleShell(s ssh.Session, args []string) { + fail := func(format string, a ...any) { + fmt.Fprintf(s.Stderr(), "objgitd: "+format+"\n", a...) + _ = s.Exit(1) + } + + if !d.allowHooks { + fail("sh is disabled; start objgitd with -allow-hooks to enable it") + return + } + if len(args) < 1 || len(args) > 2 { + fail(shellUsage) + return + } + _, winCh, isPty := s.Pty() + if !isPty { + fail("sh needs a terminal; use ssh -t") + return + } + + ref, err := repofs.Parse(args[0]) + if err != nil { + fail("%v", err) + return + } + var branch string + if len(args) == 2 { + branch = args[1] + } + + var cred auth.Credential = auth.Anonymous{} + if key := s.PublicKey(); key != nil { + cred = auth.PublicKey{Key: key} + } + + defer metrics.TrackInFlight("ssh")() + start := time.Now() + + if d.authorize(s.Context(), auth.Request{ + Repo: ref.Path(), + Operation: operationFor(transport.ReceivePackService), + Cred: cred, + Transport: "ssh", + }) != auth.Allow { + metrics.ObserveGitOp("ssh", "sh", "denied", start) + fail("access denied") + return + } + + // SSH carries no Basic-auth credential into the filesystem resolver. + var none repofs.Credential + st, err := d.load(s.Context(), ref, none) + if err != nil { + metrics.ObserveGitOp("ssh", "sh", "error", start) + fail("repository %q not found", ref.Path()) + return + } + u, tree, err := shellTarget(st, branch) + if err != nil { + metrics.ObserveGitOp("ssh", "sh", "error", start) + fail("%v", err) + return + } + // A push hook diffs within its -hook-timeout, so bound this diff the same way. + diffCtx, cancel := context.WithTimeout(s.Context(), d.hookTimeout) + changes, err := loadHookChanges(diffCtx, st, u) + cancel() + if err != nil { + metrics.ObserveGitOp("ssh", "sh", "error", start) + slog.Error("ssh shell: diff changed files", "repo", ref.Path(), "sha", u.New.String(), "err", err) + fail("cannot list the changed files of %s", u.New) + return + } + + log := slog.With("repo", ref.Path(), "ref", u.Name.String(), "sha", u.New.String(), "remote", s.RemoteAddr().String()) + log.Info("serving ssh shell") + + in := &shellInput{ReadWriter: s} + t := term.NewTerminal(in, "$ ") + // gliderlabs blocks the session's request loop until each window change is + // received, so drain the channel for the whole session. It closes with the + // session. + go func() { + for win := range winCh { + // A client without a local terminal (ssh -tt < file) reports 0x0; + // keep x/term's 80x24 default rather than wrap at every column. + if win.Width > 0 && win.Height > 0 { + _ = t.SetSize(win.Width, win.Height) + } + } + }() + + sh, err := newHookShell(tree, changes, hookEnv(ref.Path(), receivePackHook, u, changes), nil, t, t) + if err != nil { + metrics.ObserveGitOp("ssh", "sh", "error", start) + log.Error("ssh shell: build shell", "err", err) + fail("cannot start shell") + return + } + + fmt.Fprintf(t, "objgit hook shell: %s %s @ %s\n", ref.Path(), u.Name, u.New) + fmt.Fprintln(t, "/src is read-only and /tmp is writable. Type exit or press Ctrl-D to leave.") + + status := d.runShell(s.Context(), t, in, sh, hookStdin(u)) + metrics.ObserveGitOp("ssh", "sh", "ok", start) + log.Info("ssh shell finished", "exit", status) + _ = s.Exit(status) +} + +// runShell reads statements from t and runs each one in sh until exit, Ctrl-D, +// or a closed session. Each statement gets a fresh copy of the hook's stdin and +// its own -hook-timeout. It returns the shell's exit status. +func (d *daemon) runShell(ctx context.Context, t *term.Terminal, in *shellInput, sh *interp.Runner, stdin string) int { + lines := &shellLines{t: t, in: in} + status := 0 + for { + // A new parser per round drops any partial statement after Ctrl-C or a + // syntax error. + lines.reset() + t.SetPrompt("$ ") + parser := syntax.NewParser(syntax.Variant(syntax.LangBash)) + for stmts, err := range parser.InteractiveSeq(lines) { + if err != nil { + // The terminal already echoed ^C for an interrupt. + if !lines.interrupted { + fmt.Fprintln(t, err) + } + break + } + if parser.Incomplete() { + t.SetPrompt("> ") + continue + } + for _, stmt := range stmts { + var exited bool + status, exited = d.runShellStmt(ctx, t, sh, stmt, stdin) + if exited { + return status + } + } + t.SetPrompt("$ ") + } + if lines.eof { + return status + } + } +} + +// runShellStmt runs one statement and returns its exit status, and whether it +// ran exit. Errors other than a plain exit status, such as a write into /src, +// are printed to t. A script run of the hook stops at such an error; the +// interactive session reports it and carries on, as bash does. +func (d *daemon) runShellStmt(ctx context.Context, t *term.Terminal, sh *interp.Runner, stmt *syntax.Stmt, stdin string) (int, bool) { + in, err := stdinPipe(stdin) + if err != nil { + fmt.Fprintln(t, "objgitd:", err) + return 1, false + } + defer in.Close() + if err := interp.StdIO(in, t, t)(sh); err != nil { + fmt.Fprintln(t, "objgitd:", err) + return 1, false + } + + ctx, cancel := context.WithTimeout(ctx, d.hookTimeout) + defer cancel() + err = sh.Run(ctx, stmt) + + var exit interp.ExitStatus + switch { + case err == nil: + return 0, sh.Exited() + case errors.Is(ctx.Err(), context.DeadlineExceeded): + fmt.Fprintf(t, "objgitd: command timed out after %s\n", d.hookTimeout) + return 1, false + case errors.As(err, &exit): + return int(exit), sh.Exited() + default: + fmt.Fprintln(t, err) + return 1, false + } +} + +// stdinPipe returns a pipe that reads s and then EOF. s is one hook stdin line, +// which fits in the pipe buffer, so the write cannot block. +func stdinPipe(s string) (*os.File, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + _, err = w.WriteString(s) + if cerr := w.Close(); err == nil { + err = cerr + } + if err != nil { + r.Close() + return nil, err + } + return r, nil +} + +// ctrlC is the byte a terminal sends for Ctrl-C. +const ctrlC = 0x03 + +// shellInput is the session as the terminal reads it. x/term reports Ctrl-C as +// io.EOF, the same as Ctrl-D, and it drops both the partial line and the rest +// of the read. shellInput therefore rewrites each Ctrl-C to "^C" and Enter +// before x/term sees it: the terminal echoes ^C as bash does, ends the line, +// and keeps the input that follows. shellLines then discards that line. +type shellInput struct { + io.ReadWriter + pending int // Ctrl-C lines that shellLines has not discarded yet + + buf []byte // rewritten input that did not fit in the caller's buffer + err error // read error held back until buf drains +} + +func (in *shellInput) Read(p []byte) (int, error) { + if len(in.buf) == 0 { + if in.err != nil { + return 0, in.err + } + n, err := in.ReadWriter.Read(p) + count := bytes.Count(p[:n], []byte{ctrlC}) + if count == 0 { + return n, err + } + in.pending += count + in.buf = bytes.ReplaceAll(p[:n], []byte{ctrlC}, []byte("^C\r")) + in.err = err + } + n := copy(p, in.buf) + in.buf = in.buf[n:] + return n, nil +} + +// shellLines feeds the parser one terminal line per Read, so line editing and +// history come from x/term. +type shellLines struct { + t *term.Terminal + in *shellInput + buf []byte + + eof bool // the session ended or Ctrl-D was pressed + interrupted bool // the parser saw errShellInterrupt +} + +func (l *shellLines) Read(p []byte) (int, error) { + if len(l.buf) == 0 { + line, err := l.t.ReadLine() + if err != nil { + l.eof = true + return 0, err + } + if l.in.pending > 0 && strings.Contains(line, "^C") { + l.in.pending-- + l.interrupted = true + return 0, errShellInterrupt + } + l.buf = append([]byte(line), '\n') + } + n := copy(p, l.buf) + l.buf = l.buf[n:] + return n, nil +} + +// reset drops buffered input and the interrupt mark before a new parser starts. +func (l *shellLines) reset() { + l.buf = nil + l.interrupted = false +} diff --git a/cmd/objgitd/shell_test.go b/cmd/objgitd/shell_test.go new file mode 100644 index 0000000..595cd62 --- /dev/null +++ b/cmd/objgitd/shell_test.go @@ -0,0 +1,397 @@ +package main + +import ( + "errors" + "fmt" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "mvdan.cc/sh/v3/expand" +) + +func TestHookEnv(t *testing.T) { + u := refUpdate{ + Name: plumbing.NewBranchReferenceName("main"), + Old: plumbing.NewHash("1111111111111111111111111111111111111111"), + New: plumbing.NewHash("2222222222222222222222222222222222222222"), + } + c := hookChanges{ + added: []byte(`["a.txt"]`), + changed: []byte(`["b.txt"]`), + deleted: []byte(`[]`), + } + env := expand.ListEnviron(hookEnv("acme/test", "receive-pack", u, c)...) + + for _, tt := range []struct { + name string + want string + }{ + {name: "HOME", want: "/tmp"}, + {name: "PWD", want: "/src"}, + {name: "TMPDIR", want: "/tmp"}, + {name: "KEFKA", want: "1"}, + {name: "OBJGIT_REPO", want: "acme/test"}, + {name: "OBJGIT_SERVICE", want: "receive-pack"}, + {name: "OBJGIT_REF", want: "refs/heads/main"}, + {name: "OBJGIT_BRANCH", want: "main"}, + {name: "OBJGIT_OLD_SHA", want: u.Old.String()}, + {name: "OBJGIT_NEW_SHA", want: u.New.String()}, + {name: "OBJGIT_ADDED_FILES_JSON", want: `["a.txt"]`}, + {name: "OBJGIT_CHANGED_FILES_JSON", want: `["b.txt"]`}, + {name: "OBJGIT_DELETED_FILES_JSON", want: `[]`}, + {name: "OBJGIT_CHANGES_FILE", want: "/tmp/objgit-changes.json"}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := env.Get(tt.name).String(); got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +// historyRepo builds an on-disk repository with two commits on main and one on +// a dev branch forked from main's first commit. It returns the work tree and +// the commit hashes by name. +func historyRepo(t *testing.T) (string, map[string]string) { + t.Helper() + work := seedRepo(t) // main: "initial" + hashes := map[string]string{} + rev := func(name string) string { + return strings.TrimSpace(runGit(t, work, "rev-parse", name)) + } + hashes["root"] = rev("HEAD") + + runGit(t, work, "commit", "--allow-empty", "-m", "second") + hashes["main"] = rev("HEAD") + + runGit(t, work, "checkout", "-b", "dev", hashes["root"]) + runGit(t, work, "commit", "--allow-empty", "-m", "on dev") + hashes["dev"] = rev("HEAD") + + runGit(t, work, "checkout", "--orphan", "lonely") + runGit(t, work, "commit", "--allow-empty", "-m", "orphan root") + hashes["lonely"] = rev("HEAD") + + runGit(t, work, "checkout", "main") + return work, hashes +} + +func TestShellTarget(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + work, hashes := historyRepo(t) + + for _, tt := range []struct { + name string + branch string + detach bool + wantRef string + wantOld string // hash name in hashes; "" means the zero hash + wantNew string + wantErr bool + errMatch string + }{ + { + name: "empty branch uses HEAD's branch", + wantRef: "refs/heads/main", + wantOld: "root", + wantNew: "main", + }, + { + name: "named branch", + branch: "dev", + wantRef: "refs/heads/dev", + wantOld: "root", + wantNew: "dev", + }, + { + name: "root commit has a zero old sha", + branch: "lonely", + wantRef: "refs/heads/lonely", + wantNew: "lonely", + }, + { + name: "missing branch", + branch: "nope", + wantErr: true, + errMatch: `branch "nope" not found`, + }, + { + name: "detached HEAD needs a branch", + detach: true, + wantErr: true, + errMatch: "HEAD is detached", + }, + } { + t.Run(tt.name, func(t *testing.T) { + repo, err := git.PlainOpen(work) + if err != nil { + t.Fatalf("open: %v", err) + } + st := repo.Storer + if tt.detach { + head := plumbing.NewHashReference(plumbing.HEAD, plumbing.NewHash(hashes["main"])) + if err := st.SetReference(head); err != nil { + t.Fatalf("detach HEAD: %v", err) + } + t.Cleanup(func() { + _ = st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))) + }) + } + + u, tree, err := shellTarget(st, tt.branch) + if tt.wantErr { + if err == nil { + t.Fatalf("shellTarget(%q) = %+v, want error", tt.branch, u) + } + if !strings.Contains(err.Error(), tt.errMatch) { + t.Errorf("error = %q, want it to contain %q", err, tt.errMatch) + } + return + } + if err != nil { + t.Fatalf("shellTarget(%q): %v", tt.branch, err) + } + if tree == nil { + t.Fatal("tree is nil") + } + if got := u.Name.String(); got != tt.wantRef { + t.Errorf("ref = %q, want %q", got, tt.wantRef) + } + wantOld := plumbing.ZeroHash + if tt.wantOld != "" { + wantOld = plumbing.NewHash(hashes[tt.wantOld]) + } + if u.Old != wantOld { + t.Errorf("old = %s, want %s", u.Old, wantOld) + } + if want := plumbing.NewHash(hashes[tt.wantNew]); u.New != want { + t.Errorf("new = %s, want %s", u.New, want) + } + }) + } +} + +// sshShell runs "ssh sh " with the given stdin. forcePTY +// passes -tt so ssh allocates a remote PTY even though stdin is a pipe. +func sshShell(t *testing.T, addr string, forcePTY bool, stdin string, args ...string) (string, int) { + t.Helper() + host, port, _ := strings.Cut(addr, ":") + sshArgs := []string{ + "-i", sshClientKey(t), + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", port, + } + if forcePTY { + sshArgs = append(sshArgs, "-tt") + } else { + sshArgs = append(sshArgs, "-T") + } + sshArgs = append(sshArgs, "git@"+host, "sh") + sshArgs = append(sshArgs, args...) + + cmd := exec.Command("ssh", sshArgs...) + cmd.Stdin = strings.NewReader(stdin) + out, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + switch { + case errors.As(err, &exitErr): + return string(out), exitErr.ExitCode() + case err != nil: + t.Fatalf("ssh: %v\n%s", err, out) + } + return string(out), 0 +} + +// TestSSHShell drives the sh command with a real ssh client against a +// repository pushed over SSH, covering the sandbox, the hook environment, the +// exit status, and each refusal path. +func TestSSHShell(t *testing.T) { + checkSSHBinaries(t) + + for _, tt := range []struct { + name string + allowPush bool + allowHooks bool + push bool + forcePTY bool + args []string + stdin string + wantCode int + want []string + }{ + { + name: "interactive session sees the hook sandbox", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + stdin: strings.Join([]string{ + `echo "new=$OBJGIT_NEW_SHA"`, + `echo "branch=$OBJGIT_BRANCH cwd=$PWD svc=$OBJGIT_SERVICE"`, + `read old new ref; echo "stdin=$ref"`, + `cat README.md`, + `echo "added=$OBJGIT_ADDED_FILES_JSON"`, + `cat "$OBJGIT_CHANGES_FILE"`, + `printf 'x%sy\n' tmp > /tmp/x && cat /tmp/x`, + `if true; then`, + `echo "cont$((1))"`, + `fi`, + `echo nope > /src/y`, + `echo "alive$((2))"`, + `exit 3`, + }, "\n") + "\n", + wantCode: 3, + want: []string{ + "objgit hook shell", + "branch=main cwd=/src svc=receive-pack\r", + "stdin=refs/heads/main", + "hello from the shell repo", + `added=["README.md"]`, + `{"added":["README.md"],"changed":[],"deleted":[]}`, + "xtmpy", + "cont1", + "read-only filesystem", + "alive2", + }, + }, + { + name: "named branch", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git", "main"}, + stdin: "echo \"ref=$OBJGIT_REF\"\nexit\n", + want: []string{"ref=refs/heads/main"}, + }, + { + name: "ctrl-c drops a partial statement", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + stdin: "if true; then\n\x03echo \"after$((5))\"\nexit 4\n", + wantCode: 4, + want: []string{"^C", "after5"}, + }, + { + name: "syntax error keeps the session", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + stdin: "fi\necho \"ok$((6))\"\nexit\n", + want: []string{"ok6"}, + }, + { + name: "eof ends the session with the last status", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + stdin: "false\n", + wantCode: 1, + }, + { + name: "no pty", + allowPush: true, + allowHooks: true, + push: true, + args: []string{"acme/shell.git"}, + wantCode: 1, + want: []string{"sh needs a terminal"}, + }, + { + name: "hooks disabled", + allowPush: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + wantCode: 1, + want: []string{"-allow-hooks"}, + }, + { + name: "write access denied", + allowHooks: true, + forcePTY: true, + args: []string{"acme/shell.git"}, + wantCode: 1, + want: []string{"access denied"}, + }, + { + name: "missing repository", + allowPush: true, + allowHooks: true, + forcePTY: true, + args: []string{"acme/missing.git"}, + wantCode: 1, + want: []string{"not found"}, + }, + { + name: "missing branch", + allowPush: true, + allowHooks: true, + push: true, + forcePTY: true, + args: []string{"acme/shell.git", "nope"}, + wantCode: 1, + want: []string{`branch "nope" not found`}, + }, + { + name: "no repository argument", + allowPush: true, + allowHooks: true, + forcePTY: true, + wantCode: 1, + want: []string{"usage: sh [branch]"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + addr, _ := startSSHServer(t, tt.allowPush, tt.allowHooks) + env := gitSSHEnv(t) + + var head string + if tt.push { + work := t.TempDir() + runGit(t, work, "init", "-b", "main") + runGit(t, work, "config", "user.email", "test@example.com") + runGit(t, work, "config", "user.name", "Test") + writeFile(t, filepath.Join(work, "README.md"), "hello from the shell repo\n") + runGit(t, work, "add", ".") + runGit(t, work, "commit", "-m", "shell repo") + head = strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) + if out, err := gitWithEnv(work, env, "push", "ssh://git@"+addr+"/acme/shell.git", "main"); err != nil { + t.Fatalf("push failed: %v\n%s", err, out) + } + } + + out, code := sshShell(t, addr, tt.forcePTY, tt.stdin, tt.args...) + if code != tt.wantCode { + t.Errorf("exit code = %d, want %d; output:\n%s", code, tt.wantCode, out) + } + want := slices.Clone(tt.want) + if tt.wantCode == 3 { + want = append(want, fmt.Sprintf("new=%s", head)) + } + for _, w := range want { + if !strings.Contains(out, w) { + t.Errorf("output missing %q; output:\n%s", w, out) + } + } + }) + } +} diff --git a/cmd/objgitd/ssh.go b/cmd/objgitd/ssh.go index 02ca1f5..0ef755a 100644 --- a/cmd/objgitd/ssh.go +++ b/cmd/objgitd/ssh.go @@ -119,11 +119,19 @@ func newSSHServer(d *daemon, addr string) (*ssh.Server, error) { // handleSSH services one git-over-SSH exec request: parse the command, authorize, // resolve the repository, and hand the session to the matching go-git transport -// command. The session is the protocol stream (reader and writer). +// command. The session is the protocol stream (reader and writer). The one +// non-git command, sh, goes to handleShell. func (d *daemon) handleSSH(s ssh.Session) { cmd := s.Command() + if len(cmd) > 0 && cmd[0] == "sh" { + d.handleShell(s, cmd[1:]) + return + } if len(cmd) < 2 { fmt.Fprintln(s.Stderr(), "objgitd: this is a git SSH endpoint; interactive shells are not supported") + if d.allowHooks { + fmt.Fprintln(s.Stderr(), "objgitd: to explore the hook sandbox, run: ssh -t sh [branch]") + } _ = s.Exit(1) return } diff --git a/cmd/objgitd/ssh_test.go b/cmd/objgitd/ssh_test.go index 0f8bdf1..8a2d2dd 100644 --- a/cmd/objgitd/ssh_test.go +++ b/cmd/objgitd/ssh_test.go @@ -148,16 +148,21 @@ func startSSHServer(t *testing.T, allowPush, allowHooks bool) (string, *memBase) return ln.Addr().String(), mb } -// gitSSHEnv generates a throw-away ed25519 client key and returns an env slice -// with GIT_SSH_COMMAND pointing at ssh using that key with host-key checking -// disabled. The server accepts any public key. -func gitSSHEnv(t *testing.T) []string { +// sshClientKey generates a throw-away ed25519 client key and returns its path. +func sshClientKey(t *testing.T) string { t.Helper() - keyDir := t.TempDir() - key := filepath.Join(keyDir, "id_ed25519") + key := filepath.Join(t.TempDir(), "id_ed25519") if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", key).CombinedOutput(); err != nil { t.Fatalf("ssh-keygen: %v\n%s", err, out) } + return key +} + +// gitSSHEnv returns an env slice with GIT_SSH_COMMAND pointing at ssh using that key with host-key checking +// disabled. The server accepts any public key. +func gitSSHEnv(t *testing.T) []string { + t.Helper() + key := sshClientKey(t) sshCmd := fmt.Sprintf("ssh -i %q -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", key) return append(os.Environ(), "GIT_SSH_COMMAND="+sshCmd, diff --git a/docs/architecture/hooks.md b/docs/architecture/hooks.md index b03f2cc..6faba85 100644 --- a/docs/architecture/hooks.md +++ b/docs/architecture/hooks.md @@ -61,13 +61,41 @@ of commands, which here is coreutils only. The sandbox filesystem is an `internal/mountfs` composite of two mounts: -| Mount | Contents | -| ------ | ------------------------------------------------------------------------ | +| Mount | Contents | +| ------ | ---------------------------------------------------------------------------------------------------------------- | | `/src` | A lazy read-only `internal/treefs` view of the commit tree. Blobs are fetched on open, with no checkout to disk. | -| `/tmp` | A writable `memfs` for scratch. `HOME` and `TMPDIR` point here. | +| `/tmp` | A writable `memfs` for scratch. `HOME` and `TMPDIR` point here. | A write outside `/tmp` fails, and a redirect into `/src` aborts the script. `internal/kefkash` vendors the unexported `billysh` handler wiring from kefka. Its `OpenHandler` is adapted to permit writes, so `/tmp` redirections work. The filesystem is what enforces the read-only `/src`. + +`newHookShell` builds this sandbox, and `hookEnv` builds its environment. +`loadHookChanges` gives both of them the file changes of the update. +`newHookShell` writes these changes to `/tmp/objgit-changes.json`. It also +sets the interpreter's `Dir` to `/src`. Without this setting, interp +copies the host working directory of the daemon into `$PWD`. + +## The interactive shell (`shell.go`) + +The SSH command `sh [branch]` opens the same sandbox as an interactive +shell. It uses `newHookShell` and `hookEnv`, so the two environments cannot +drift apart. `shellTarget` fills the environment from the tip of the branch. +`OBJGIT_OLD_SHA` is the first parent of that commit, as if the commit was +just pushed. + +`golang.org/x/term` gives line editing and history over the session. +`syntax.Parser.InteractiveSeq` reads statements from it. Each statement gets a +new copy of the hook stdin line and its own `-hook-timeout`. + +Two points are easy to get wrong: + +- x/term returns `io.EOF` for Ctrl-C, the same as for Ctrl-D. It also drops + the partial line and the rest of the read. `shellInput` therefore changes + each Ctrl-C byte to `^C` and Enter before x/term reads it. `shellLines` then + discards that line. +- A fatal error, such as a write into `/src`, sets `Runner.Exited`. A hook + script stops at this error. The interactive shell prints the error and + continues. It stops only for `exit`, Ctrl-D, or a closed session. diff --git a/docs/architecture/transports.md b/docs/architecture/transports.md index 600080b..ae3900f 100644 --- a/docs/architecture/transports.md +++ b/docs/architecture/transports.md @@ -59,6 +59,11 @@ restart, and the server needs no local disk. Receive-pack goes through `d.receivePack`, and not through `transport.ReceivePack`, so [push hooks](hooks.md) also run over SSH. +**One command is not git.** `sh [branch]` goes to `handleShell` in +`shell.go`, before `gitServiceFor` runs. It opens the [hook sandbox](hooks.md) +as an interactive shell over the session PTY. It needs `-allow-hooks`, a PTY, +and `auth.Write` on the repository, because only a pusher can trigger a hook. + Protocol v2 is not forwarded yet. `s.Environ()` carries `GIT_PROTOCOL`, but v0 and v1 are enough for now. diff --git a/docs/usage/hooks.md b/docs/usage/hooks.md index 08a66b9..3d03456 100644 --- a/docs/usage/hooks.md +++ b/docs/usage/hooks.md @@ -146,6 +146,57 @@ A copy of this example lives at [`.objgit/hooks/receive-pack`](../../.objgit/hooks/receive-pack) in this repository. +## Exploring the sandbox + +You can open the hook sandbox as an interactive shell over SSH. Use it to try +commands before you put them in a hook. + +1. Start `objgitd` with `-ssh-bind` and `-allow-hooks`. +2. Push the branch that you want to examine. +3. Connect with a terminal: + + ```text + ssh -t -p 2222 git@host sh myproject.git + ``` + +4. To examine a branch other than the one that `HEAD` points to, give its name: + + ```text + ssh -t -p 2222 git@host sh myproject.git feature + ``` + +The shell is the same as the one that a hook gets: + +- `/src` is the tip commit of the branch, read-only, and the shell starts there. +- `/tmp` is scratch space. At the start of each session, it holds only + `objgit-changes.json`. +- The `OBJGIT_*` variables describe the tip commit as if you pushed it. + `OBJGIT_OLD_SHA` is its first parent, or all zeros for a root commit. +- The file lists compare the tip commit with that first parent. For a root + commit, every file is in `OBJGIT_ADDED_FILES_JSON`. +- Each command gets the hook stdin line, so `read old new ref` works. + +The shell is different from a hook in these ways: + +- A write into `/src` shows an error, but the session continues. +- `-hook-timeout` applies to each command, and not to the session. +- Ctrl-C clears the line at the prompt. It does not stop a command that runs. + The timeout stops that command. + +To leave the shell, type `exit` or press Ctrl-D. + +To open the shell, you need write access to the repository. If the server +refuses the session, it shows one of these errors: + +| Error | Cause | +| -------------------------------------------------------------- | ------------------------------------------- | +| `sh is disabled; start objgitd with -allow-hooks to enable it` | The server runs without `-allow-hooks`. | +| `sh needs a terminal; use ssh -t` | The client did not request a PTY. | +| `access denied` | You do not have write access. | +| `repository "…" not found` | The repository does not exist. | +| `branch "…" not found` | The branch does not exist. | +| `HEAD is detached; name a branch: …` | `HEAD` is not a branch. Give a branch name. | + ## Observing hooks All hook activity is logged through the server's structured (`slog`) logger: diff --git a/go.mod b/go.mod index e5b068e..d6e8466 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( go.uber.org/atomic v1.11.0 golang.org/x/crypto v0.51.0 golang.org/x/sync v0.20.0 + golang.org/x/term v0.43.0 mvdan.cc/sh/v3 v3.13.1 tangled.org/xeiaso.net/kefka v0.0.6-0.20260528192045-e0a84e40ceb8 ) @@ -65,7 +66,6 @@ require ( golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect - golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.8