openvibers/openvibe.link Commits

Git on this computer: clone with approval, and a published machine (#148)

OP OpenVibers committed 3 days ago · fcdd14c682 · 8 files, +1546 −2

* Git on this computer: clone with approval, and a published machine

* The relay tests run on Windows and macOS too

* A Windows checkout writes CRLF: compare the relay README after normalizing

* An unapproved clone writes nothing, tested both with no window and a refusal

Changes

ddiff --git a/README.md b/README.md
iindex 28fc8dd..c76c94d 100644
---- a/README.md
++++ b/README.md
@@@ -129,6 +129,14 @@ Dock's activity read-only, and **Send to OpenVibe** from any app's share sheet t
openvibe.bot/link/phone and shows up as a device of kind `phone`. No phone automation at all in this version: no
AccessibilityService, no screen capture. Build, install and the security notes: [docs/android.md](docs/android.md).
++**Git on this computer (Git plan G3):** `openvibe-link clone '<link>'` serves a "Clone to my computer" request from
++git.openvibe.codes: it shows the repository and the folder, asks you, clones with your own git inside the root you
++chose (`--root`, default `~/OpenVibe`), reports progress to the page, and refuses an expired request, a folder
++outside the root, or a non-empty folder without a second answer. One clone runs at a time per machine. The page's
++`openvibe-link://clone?url=…` deep link is the same request. `openvibe-link git connect '<link>'` publishes the
++repositories under `--root` to OpenVibe Git, read-only, until Ctrl-C: the page lists them and browses tree, file and
++log through the poll the Link opened. Neither command opens a port on this computer; both connect out to Git.
++
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
[panel](docs/panel.md) (Link's own window and its security rules),
ddiff --git a/STATUS.json b/STATUS.json
iindex 6b70ce3..cf81963 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,13 +5,14 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.51.0",
-- "release": "v0.51.0: OpenVibe Link by name. The binaries, packages, installer, service unit and folders are openvibe-link (was openvibe-node); machines pair as lnk_ principals with link: tokens (Network migration 0062); the repository is OpenVibers/OpenVibe.Link. A machine installed as openvibe-node pairs again with the new installer.",
++ "version": "0.52.0",
++ "release": "v0.52.0: Git on this computer (Git plan G3). `openvibe-link clone` serves a Clone to my computer request from git.openvibe.codes: approval in Link, the root the person chose, a non-empty folder needing a second answer, one clone at a time, a two-minute request, progress to the page. `openvibe-link git connect` publishes the repositories under the root, read-only, and browses tree, file and log through the poll the machine opened. Both connect out to Git; neither opens a port.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
"deployment": "none of its own: people install it on their machines and robots (curl -fsSL https://openvibe.link/install | sh, or a release binary); it connects out to OpenVibe.Bot (wss://openvibe.bot/device) and pairs through OpenVibe.Bot or OpenVibe.Network. On 2026-10-22 production held 1 platform node with 4 pairings and 5 node principals on Network, and 3 robots with 5 devices on Bot.",
"features": [
++ "Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online",
"pairing with a code approved on openvibe.bot (or through Network), one outbound WebSocket, no open ports",
"safety: deadlines, owner limits clamped before any driver, heartbeats and a deadman, a persisted e-stop and the local kill switch (openvibe-link stop)",
"plugins as separate processes (JSON lines): dryrun, adeept_adr036, cozmo, relay and ros2 (rclpy or rosbridge); the community LOOI driver's contract; signed community profiles (openvibe-link profile verify)",
ddiff --git a/cmd/openvibe-link/git.go b/cmd/openvibe-link/git.go
nnew file mode 100644
iindex 0000000..35fbd09
---- /dev/null
++++ b/cmd/openvibe-link/git.go
@@@ -0,0 +1,101 @@
++package main
++
++import (
++ "bufio"
++ "context"
++ "errors"
++ "fmt"
++ "io"
++ "os"
++ "os/signal"
++ "path/filepath"
++ "strings"
++ "syscall"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"
++)
++
++// cmdClone serves one "Clone to my computer" link from a repository page. The person sees the repository and the
++// folder here and answers; git runs with their own git, inside the root they chose, and the page shows progress.
++func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io.Writer) error {
++ if root == "" {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ return err
++ }
++ root = filepath.Join(home, "OpenVibe")
++ }
++ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
++ defer stop()
++ approve := func(_ context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {
++ _, name, err := gitrelay.RepoName(req.Repo)
++ if err != nil {
++ return gitrelay.CloneDecision{}, err
++ }
++ folder := filepath.Join(root, name)
++ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)
++ if yes {
++ return gitrelay.CloneDecision{Approve: true, Folder: folder}, nil
++ }
++ ok, err := confirm(stdin, stdout, "Clone it here?")
++ if err != nil {
++ return gitrelay.CloneDecision{}, err
++ }
++ return gitrelay.CloneDecision{Approve: ok, Folder: folder}, nil
++ }
++ overwrite := func(_ context.Context, req gitrelay.CloneRequest, folder string) (bool, error) {
++ fmt.Fprintf(stdout, "\n%s already has files in it. Cloning into it adds this repository's files beside them.\n", folder)
++ if yes {
++ return true, nil
++ }
++ return confirm(stdin, stdout, "Clone into it anyway?")
++ }
++ res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})
++ if err != nil {
++ return err
++ }
++ if res.Reason != "" {
++ fmt.Fprintln(stdout, res.Reason+"; nothing was written.")
++ return nil
++ }
++ fmt.Fprintln(stdout, "Cloned into", res.Folder)
++ return nil
++}
++
++// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C. It needs a link
++// from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.
++func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {
++ if root == "" {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ return err
++ }
++ root = filepath.Join(home, "OpenVibe")
++ }
++ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
++ defer stop()
++ fmt.Fprintf(stdout, "Publishing the repositories under %s to OpenVibe Git, read-only, until Ctrl-C.\n", root)
++ return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{Root: root, Log: g.log, Name: hostName()})
++}
++
++func hostName() string {
++ h, _ := os.Hostname()
++ return h
++}
++
++// confirm reads one yes/no answer. Anything but y or yes is no, including end of input.
++func confirm(stdin io.Reader, stdout io.Writer, question string) (bool, error) {
++ if stdin == nil {
++ return false, errors.New("there is no terminal to ask on; pass --yes to approve from a script")
++ }
++ fmt.Fprintf(stdout, "%s [y/N] ", question)
++ line, err := bufio.NewReader(stdin).ReadString('\n')
++ if err != nil && !errors.Is(err, io.EOF) {
++ return false, err
++ }
++ switch strings.ToLower(strings.TrimSpace(line)) {
++ case "y", "yes":
++ return true, nil
++ }
++ return false, nil
++}
ddiff --git a/cmd/openvibe-link/main.go b/cmd/openvibe-link/main.go
iindex 4826f54..eceffa2 100644
---- a/cmd/openvibe-link/main.go
++++ b/cmd/openvibe-link/main.go
@@@ -104,6 +104,10 @@ Usage:
behind OpenVibe sign-in, until Ctrl-C
openvibe-link share add|list|remove … the shares the Link service keeps up (openvibe-link share)
openvibe-link party host <party-id> host a shared browser for an openvibe.party room, until stopped
++ openvibe-link clone <LINK> clone a repository here: the "Clone to my computer" link from
++ its page on git.openvibe.codes, approved in this terminal
++ openvibe-link git connect <LINK> publish the repositories under --root to OpenVibe Git, read-only,
++ until Ctrl-C (the link is on your Git page)
openvibe-link version
Global flags (before or after the command):
@@@ -206,6 +210,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
shPort = fs.Int("port", 0, "the local port to share (share)")
shSrv = fs.String("connect-server", "", "OpenVibe Connect's tunnel URL; default wss://connect.openvibe.link/tunnel (share)")
shID = fs.String("share", "", "a share this computer serves itself, as OpenVibe Link: its shr_… id, no key (share)")
++ gitRoot = fs.String("root", "", "the folder Link may clone into or publish from (clone, git); default ~/OpenVibe")
++ yes = fs.Bool("yes", false, "approve this clone without asking (clone; for a script, never for a person)")
)
// Accept flags anywhere: split positionals out.
var positional []string
@@@ -297,6 +303,18 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 2
}
err = cmdProfileVerify(g, positional[1], *server, *keyID, stdout)
++ case "clone":
++ if len(positional) != 1 {
++ fmt.Fprintln(stderr, "usage: openvibe-link clone <LINK> [--root DIR] [--yes]")
++ return 2
++ }
++ err = cmdClone(g, positional[0], *gitRoot, *yes, stdin, stdout)
++ case "git":
++ if len(positional) != 2 || positional[0] != "connect" {
++ fmt.Fprintln(stderr, "usage: openvibe-link git connect <LINK> [--root DIR]")
++ return 2
++ }
++ err = cmdGitConnect(g, positional[1], *gitRoot, stdout)
case "share":
err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)
if errors.Is(err, errUsage) {
ddiff --git a/internal/gitrelay/clone.go b/internal/gitrelay/clone.go
nnew file mode 100644
iindex 0000000..5047e00
---- /dev/null
++++ b/internal/gitrelay/clone.go
@@@ -0,0 +1,381 @@
++package gitrelay
++
++import (
++ "bufio"
++ "context"
++ "errors"
++ "fmt"
++ "io"
++ "log/slog"
++ "net/http"
++ "os"
++ "os/exec"
++ "path/filepath"
++ "regexp"
++ "strconv"
++ "strings"
++ "sync"
++ "sync/atomic"
++ "syscall"
++ "time"
++)
++
++// CloneDecision is the person's answer in Link: no, or yes with the folder to use ("" is <root>/<repo name>).
++// Folder is a path inside the root; a folder outside it is refused, not clamped.
++type CloneDecision struct {
++ Approve bool
++ Folder string
++}
++
++// CloneOptions tune one clone. The zero value still works: the root is ~/OpenVibe and a nil Approve refuses.
++type CloneOptions struct {
++ Root string // the root the person chose in Link
++ Client *http.Client
++ Approve func(ctx context.Context, req CloneRequest) (CloneDecision, error)
++ ApproveOverwrite func(ctx context.Context, req CloneRequest, folder string) (bool, error)
++ Log *slog.Logger
++ Now func() time.Time
++ GitBin string
++ // AllowLocal lets a test clone a file:// repository. Production links are https only.
++ AllowLocal bool
++}
++
++// CloneResult is what Link did with one request.
++type CloneResult struct {
++ Folder string
++ Reason string
++}
++
++// ErrBusy is another clone running on this machine. Only one runs at a time, whichever process asked.
++var ErrBusy = errors.New("another clone is already running on this computer")
++
++var cloneBusy atomic.Bool
++
++var (
++ progressPercentRe = regexp.MustCompile(`(\d{1,3})%`)
++ lockName = ".openvibe-link-clone.lock"
++)
++
++// Clone serves one clone request: fetch it, ask the person, check the folder, clone with the person's own git and
++// report each step back. It refuses before running anything when the request has expired.
++func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error) {
++ if o.Log == nil {
++ o.Log = slog.Default()
++ }
++ if o.Now == nil {
++ o.Now = time.Now
++ }
++ if o.GitBin == "" {
++ o.GitBin = "git"
++ }
++ ep, err := ParseEndpoint(raw)
++ if err != nil {
++ return CloneResult{}, err
++ }
++ if ep.Kind != "clone" {
++ return CloneResult{}, errors.New("that link is for a published machine, not a clone")
++ }
++ var req CloneRequest
++ if err := call(ctx, o.Client, "GET", linkURL(ep, ""), nil, &req); err != nil {
++ return CloneResult{}, err
++ }
++ if req.State != "pending" {
++ return CloneResult{Reason: req.Reason}, fmt.Errorf("the request is already %s", req.State)
++ }
++ if req.ExpiresAt.Before(o.Now()) {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before your Link answered it"})
++ return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
++ }
++ if !cloneBusy.CompareAndSwap(false, true) {
++ return CloneResult{}, ErrBusy
++ }
++ defer cloneBusy.Store(false)
++
++ root, err := prepareRoot(o.Root)
++ if err != nil {
++ return CloneResult{}, err
++ }
++ unlock, err := lockMachine(root)
++ if err != nil {
++ return CloneResult{}, err
++ }
++ defer unlock()
++
++ _, repoName, err := RepoName(req.Repo)
++ if err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the repository name is not valid"})
++ return CloneResult{}, err
++ }
++ folder := filepath.Join(root, repoName)
++ if o.Approve == nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "nothing on this computer asked for approval"})
++ return CloneResult{}, errors.New("no approval on this computer")
++ }
++ decision, err := o.Approve(ctx, req)
++ if err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the approval could not be asked: " + short(err.Error())})
++ return CloneResult{}, err
++ }
++ if !decision.Approve {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "refused in Link"})
++ return CloneResult{Reason: "refused in Link"}, nil
++ }
++ if decision.Folder != "" {
++ folder = decision.Folder
++ if !filepath.IsAbs(folder) {
++ folder = filepath.Join(root, folder)
++ }
++ }
++ folder, err = insideRoot(root, folder)
++ if err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder is outside the root you chose in Link"})
++ return CloneResult{}, err
++ }
++ if o.Now().After(req.ExpiresAt) {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before the clone started"})
++ return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
++ }
++ nonEmpty, err := folderNonEmpty(folder)
++ if err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the folder could not be read"})
++ return CloneResult{}, err
++ }
++ if nonEmpty {
++ overwrite := false
++ if o.ApproveOverwrite != nil {
++ overwrite, err = o.ApproveOverwrite(ctx, req, folder)
++ if err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the overwrite could not be asked: " + short(err.Error())})
++ return CloneResult{}, err
++ }
++ }
++ if !overwrite {
++ _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder already has files in it"})
++ return CloneResult{Reason: "the folder already has files in it"}, nil
++ }
++ }
++ if !o.AllowLocal && !strings.HasPrefix(req.CloneURL, "https://") {
++ _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the clone URL is not https"})
++ return CloneResult{}, errors.New("the clone URL is not https")
++ }
++ _ = postState(ctx, o, ep, map[string]any{"state": "approved"})
++ if err := postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: 0, Line: "Starting"}}); err != nil {
++ return CloneResult{}, err
++ }
++ if err := runClone(ctx, o, ep, req.CloneURL, folder, repoName); err != nil {
++ return CloneResult{Folder: folder, Reason: err.Error()}, err
++ }
++ _ = postState(ctx, o, ep, map[string]any{
++ "state": "done", "path": folder,
++ "progress": Progress{Percent: 100, Line: "Cloned into " + repoName},
++ })
++ return CloneResult{Folder: folder}, nil
++}
++
++func postState(ctx context.Context, o CloneOptions, ep Endpoint, body map[string]any) error {
++ // The final answer should not be lost because the context ended with the clone.
++ postCtx := ctx
++ if ctx.Err() != nil {
++ var cancel context.CancelFunc
++ postCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
++ defer cancel()
++ }
++ return call(postCtx, o.Client, "POST", linkURL(ep, ""), body, nil)
++}
++
++func prepareRoot(root string) (string, error) {
++ if strings.TrimSpace(root) == "" {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ return "", err
++ }
++ root = filepath.Join(home, "OpenVibe")
++ }
++ abs, err := filepath.Abs(root)
++ if err != nil {
++ return "", err
++ }
++ if err := os.MkdirAll(abs, 0o700); err != nil {
++ return "", fmt.Errorf("the root %s could not be made: %w", abs, err)
++ }
++ resolved, err := filepath.EvalSymlinks(abs)
++ if err != nil {
++ return "", err
++ }
++ return resolved, nil
++}
++
++// insideRoot proves folder is the root or below it after symlinks, so a name with .. or a symlinked parent can
++// never put the clone outside the root the person chose.
++func insideRoot(root, folder string) (string, error) {
++ abs, err := filepath.Abs(folder)
++ if err != nil {
++ return "", err
++ }
++ // The folder itself may not exist yet: resolve its nearest existing parent.
++ parent, base := filepath.Dir(abs), filepath.Base(abs)
++ for {
++ if _, err := os.Lstat(parent); err == nil {
++ break
++ }
++ next := filepath.Dir(parent)
++ if next == parent {
++ break
++ }
++ base = filepath.Join(filepath.Base(parent), base)
++ parent = next
++ }
++ resolvedParent, err := filepath.EvalSymlinks(parent)
++ if err != nil {
++ return "", err
++ }
++ candidate := filepath.Join(resolvedParent, base)
++ rel, err := filepath.Rel(root, candidate)
++ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
++ return "", fmt.Errorf("%s is outside %s", folder, root)
++ }
++ // If it exists, it must not be a symlink: that could point anywhere.
++ if fi, err := os.Lstat(candidate); err == nil && fi.Mode()&os.ModeSymlink != 0 {
++ return "", fmt.Errorf("%s is a symlink", folder)
++ }
++ return candidate, nil
++}
++
++func folderNonEmpty(folder string) (bool, error) {
++ entries, err := os.ReadDir(folder)
++ if errors.Is(err, os.ErrNotExist) {
++ return false, nil
++ }
++ if err != nil {
++ return false, err
++ }
++ return len(entries) > 0, nil
++}
++
++// lockMachine keeps one clone at a time per root, across processes: O_EXCL lock file with the PID. A lock left by
++// a process that no longer runs is removed.
++func lockMachine(root string) (func(), error) {
++ path := filepath.Join(root, lockName)
++ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
++ if err != nil {
++ if !errors.Is(err, os.ErrExist) {
++ return nil, err
++ }
++ if pid := readPID(path); pid > 0 {
++ if p, e := os.FindProcess(pid); e == nil {
++ if e = p.Signal(syscall.Signal(0)); e == nil {
++ return nil, ErrBusy
++ }
++ }
++ }
++ _ = os.Remove(path)
++ f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
++ if err != nil {
++ return nil, ErrBusy
++ }
++ }
++ _, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
++ _ = f.Close()
++ return func() { _ = os.Remove(path) }, nil
++}
++
++func readPID(path string) int {
++ b, err := os.ReadFile(path)
++ if err != nil {
++ return 0
++ }
++ pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
++ return pid
++}
++
++func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder, repoName string) error {
++ cmd := exec.CommandContext(ctx, o.GitBin, "clone", "--progress", "--", cloneURL, folder)
++ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never")
++ stderr, err := cmd.StderrPipe()
++ if err != nil {
++ return err
++ }
++ stdout, err := cmd.StdoutPipe()
++ if err != nil {
++ return err
++ }
++ if err := cmd.Start(); err != nil {
++ _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "git could not start"})
++ return errors.New("git could not start")
++ }
++ var lastLine atomic.Value
++ lastLine.Store("")
++ var lastPost atomic.Int64
++ lastPost.Store(time.Now().Add(-time.Hour).UnixNano())
++ var wg sync.WaitGroup
++ read := func(r io.Reader) {
++ defer wg.Done()
++ sc := bufio.NewScanner(r)
++ sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
++ sc.Split(splitProgress)
++ for sc.Scan() {
++ line := strings.TrimSpace(sc.Text())
++ if line == "" {
++ continue
++ }
++ line = short(line)
++ lastLine.Store(line)
++ now := time.Now()
++ if now.UnixNano()-lastPost.Load() < int64(400*time.Millisecond) && !strings.Contains(line, "Cloning into") {
++ continue
++ }
++ lastPost.Store(now.UnixNano())
++ percent := 0
++ if m := progressPercentRe.FindStringSubmatch(line); m != nil {
++ percent, _ = strconv.Atoi(m[1])
++ if percent > 100 {
++ percent = 100
++ }
++ }
++ _ = postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: percent, Line: line}})
++ }
++ }
++ wg.Add(2)
++ go read(stderr)
++ go read(stdout)
++ err = cmd.Wait()
++ wg.Wait()
++ if err != nil {
++ reason := lastLine.Load().(string)
++ if reason == "" {
++ reason = "git clone failed"
++ }
++ _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": reason})
++ return fmt.Errorf("git clone failed: %s", reason)
++ }
++ _ = repoName
++ return nil
++}
++
++// splitProgress splits git's progress on either CR (its progress meter) or LF.
++func splitProgress(data []byte, atEOF bool) (advance int, token []byte, err error) {
++ for i, b := range data {
++ if b == '\r' || b == '\n' {
++ return i + 1, data[:i], nil
++ }
++ }
++ if atEOF && len(data) > 0 {
++ return len(data), data, nil
++ }
++ return 0, nil, nil
++}
++
++// short makes a line safe for the page: one line, no control characters, at most 200 runes.
++func short(s string) string {
++ s = strings.Map(func(r rune) rune {
++ if r < 0x20 || r == 0x7f {
++ return ' '
++ }
++ return r
++ }, s)
++ s = strings.Join(strings.Fields(s), " ")
++ if len([]rune(s)) > 200 {
++ s = string([]rune(s)[:199]) + "…"
++ }
++ return s
++}
ddiff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
nnew file mode 100644
iindex 0000000..1bed966
---- /dev/null
++++ b/internal/gitrelay/gitrelay.go
@@@ -0,0 +1,198 @@
++// Package gitrelay is Link's side of OpenVibe Git's "clone to my computer" and "your computer as a git host"
++// (Git plan G3). Link connects out to OpenVibe.Git: there is no listening port and no inbound request, the same
++// shape as Link's Connect relay. One link carries a clone request:
++//
++// GET <base>/api/v1/link/clone-requests/<clr_…>?token=<one-time> the requested owner/name
++// POST <base>/api/v1/link/clone-requests/<clr_…>?token=<one-time> approved, progress, done, refused, failed
++//
++// and another keeps a machine published, read-only:
++//
++// POST <base>/api/v1/link/machines/<mch_…>/hello?token=<one-time> the machine and the repos under the root
++// GET <base>/api/v1/link/machines/<mch_…>/watch?token=<one-time> one browsing request, or a timeout
++// POST <base>/api/v1/link/machines/<mch_…>/results?token=<one-time> tree, file or log
++//
++// A token is in the link the page hands to the person; it is never logged. The page polls the same documents over
++// its own signed-in session (contracts: git.clone-request@1, git.machine@1).
++package gitrelay
++
++import (
++ "context"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "io"
++ "net/http"
++ "net/url"
++ "regexp"
++ "strings"
++ "time"
++)
++
++var (
++ requestIDRe = regexp.MustCompile(`^clr_[0-9A-HJKMNP-TV-Z]{26}$`)
++ machineIDRe = regexp.MustCompile(`^mch_[0-9A-HJKMNP-TV-Z]{26}$`)
++ repoRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}/[a-z0-9][a-z0-9._-]{0,99}$`)
++)
++
++// Endpoint is one link the page gave the person: the Git origin, what kind of relay it is, its id and token.
++type Endpoint struct {
++ Base string // https://git.openvibe.codes (never a path)
++ Kind string // "clone" or "machine"
++ ID string // clr_… or mch_…
++ Token string
++}
++
++// ParseEndpoint reads an https link from a repository page, or the `openvibe-link://clone?url=…` deep link that
++// wraps one. Plain http is accepted for a loopback server only (tests and a local Git).
++func ParseEndpoint(raw string) (Endpoint, error) {
++ raw = strings.TrimSpace(raw)
++ if raw == "" {
++ return Endpoint{}, errors.New("the link is empty")
++ }
++ if strings.HasPrefix(raw, "openvibe-link://") {
++ u, err := url.Parse(raw)
++ if err != nil {
++ return Endpoint{}, errors.New("not a valid openvibe-link:// link")
++ }
++ if strings.ToLower(u.Host) != "clone" {
++ return Endpoint{}, fmt.Errorf("openvibe-link://%s is not a clone link", u.Host)
++ }
++ raw = u.Query().Get("url")
++ if raw == "" {
++ return Endpoint{}, errors.New("the openvibe-link:// clone link has no url")
++ }
++ }
++ u, err := url.Parse(raw)
++ if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" {
++ return Endpoint{}, errors.New("the link is not an https address with an id and token")
++ }
++ if u.Scheme != "https" && !(u.Scheme == "http" && isLoopback(u.Hostname())) {
++ return Endpoint{}, errors.New("refusing a link that is not https (plain http is for a loopback server only)")
++ }
++ token := u.Query().Get("token")
++ if len(token) < 20 || len(token) > 200 || strings.ContainsAny(token, " \t\r\n") {
++ return Endpoint{}, errors.New("the link has no usable token")
++ }
++ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
++ // api/v1/link/clone-requests/<id> or api/v1/link/machines/<id>
++ if len(parts) != 5 || parts[0] != "api" || parts[1] != "v1" || parts[2] != "link" {
++ return Endpoint{}, errors.New("the link is not a Link relay address")
++ }
++ ep := Endpoint{Base: u.Scheme + "://" + u.Host, Token: token, ID: parts[4]}
++ switch parts[3] {
++ case "clone-requests":
++ if !requestIDRe.MatchString(ep.ID) {
++ return Endpoint{}, errors.New("the link has no clone request id (clr_…)")
++ }
++ ep.Kind = "clone"
++ case "machines":
++ if !machineIDRe.MatchString(ep.ID) {
++ return Endpoint{}, errors.New("the link has no machine id (mch_…)")
++ }
++ ep.Kind = "machine"
++ default:
++ return Endpoint{}, errors.New("the link is not a Link relay address")
++ }
++ return ep, nil
++}
++
++func isLoopback(host string) bool {
++ return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
++}
++
++// Progress is git's own line and the percentage it reported.
++type Progress struct {
++ Percent int `json:"percent"`
++ Line string `json:"line"`
++}
++
++// CloneRequest is contracts/git/clone-request.v1.json.
++type CloneRequest struct {
++ ID string `json:"id"`
++ Repo string `json:"repo"`
++ State string `json:"state"`
++ CloneURL string `json:"clone_url,omitempty"`
++ CreatedAt time.Time `json:"created_at"`
++ ExpiresAt time.Time `json:"expires_at"`
++ Progress *Progress `json:"progress,omitempty"`
++ Path string `json:"path,omitempty"`
++ Reason string `json:"reason,omitempty"`
++}
++
++// RepoName is the folder name of a repository (the part after the slash), once the shape is checked.
++func RepoName(repo string) (owner, name string, err error) {
++ if !repoRe.MatchString(repo) {
++ return "", "", fmt.Errorf("%q is not an owner/name repository", repo)
++ }
++ owner, name, _ = strings.Cut(repo, "/")
++ if strings.HasSuffix(name, ".git") || name == "." || name == ".." || owner == "." || owner == ".." {
++ return "", "", fmt.Errorf("%q is not an owner/name repository", repo)
++ }
++ return owner, name, nil
++}
++
++// httpError is a Git answer that was not 2xx; the body is a problem document, read for its detail only.
++type httpError struct {
++ Status int
++ Code string
++ Detail string
++}
++
++func (e *httpError) Error() string {
++ if e.Detail != "" {
++ return fmt.Sprintf("Git answered %d: %s", e.Status, e.Detail)
++ }
++ return fmt.Sprintf("Git answered %d", e.Status)
++}
++
++type problemDoc struct {
++ Code string `json:"code"`
++ Detail string `json:"detail"`
++}
++
++func call(ctx context.Context, client *http.Client, method, target string, body any, out any) error {
++ if client == nil {
++ client = &http.Client{Timeout: 30 * time.Second}
++ }
++ var rdr io.Reader
++ if body != nil {
++ b, err := json.Marshal(body)
++ if err != nil {
++ return err
++ }
++ rdr = strings.NewReader(string(b))
++ }
++ req, err := http.NewRequestWithContext(ctx, method, target, rdr)
++ if err != nil {
++ return err
++ }
++ if body != nil {
++ req.Header.Set("Content-Type", "application/json")
++ }
++ req.Header.Set("Accept", "application/json")
++ req.Header.Set("User-Agent", "openvibe-link/gitrelay")
++ resp, err := client.Do(req)
++ if err != nil {
++ return err
++ }
++ defer resp.Body.Close()
++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
++ if err != nil {
++ return err
++ }
++ if resp.StatusCode/100 != 2 {
++ var p problemDoc
++ _ = json.Unmarshal(raw, &p)
++ return &httpError{Status: resp.StatusCode, Code: p.Code, Detail: p.Detail}
++ }
++ if out != nil && len(raw) > 0 {
++ if err := json.Unmarshal(raw, out); err != nil {
++ return fmt.Errorf("Git's answer is not valid JSON: %w", err)
++ }
++ }
++ return nil
++}
++
++func linkURL(ep Endpoint, suffix string) string {
++ return ep.Base + "/api/v1/link/" + map[string]string{"clone": "clone-requests", "machine": "machines"}[ep.Kind] + "/" + ep.ID + suffix + "?token=" + url.QueryEscape(ep.Token)
++}
ddiff --git a/internal/gitrelay/gitrelay_test.go b/internal/gitrelay/gitrelay_test.go
nnew file mode 100644
iindex 0000000..c993cdd
---- /dev/null
++++ b/internal/gitrelay/gitrelay_test.go
@@@ -0,0 +1,291 @@
++package gitrelay
++
++import (
++ "context"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "net/url"
++ "os"
++ "os/exec"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "sync"
++ "testing"
++ "time"
++)
++
++// fileURL is a file:// URL git understands on this system: file:///C:/… on Windows, file:///tmp/… elsewhere.
++func fileURL(dir string) string {
++ p := filepath.ToSlash(dir)
++ if runtime.GOOS == "windows" && !strings.HasPrefix(p, "/") {
++ p = "/" + p
++ }
++ return (&url.URL{Scheme: "file", Path: p}).String()
++}
++
++func TestParseEndpoint(t *testing.T) {
++ id := "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA"
++ token := strings.Repeat("a", 43)
++ cases := []struct {
++ in string
++ kind string
++ ok bool
++ }{
++ {"https://git.openvibe.codes/api/v1/link/clone-requests/" + id + "?token=" + token, "clone", true},
++ {"openvibe-link://clone?url=" + "https%3A%2F%2Fgit.openvibe.codes%2Fapi%2Fv1%2Flink%2Fclone-requests%2F" + id + "%3Ftoken%3D" + token, "clone", true},
++ {"http://127.0.0.1:5270/api/v1/link/machines/mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA?token=" + token, "machine", true},
++ {"http://git.openvibe.codes/api/v1/link/clone-requests/" + id + "?token=" + token, "", false},
++ {"https://git.openvibe.codes/api/v1/link/clone-requests/clone_1?token=" + token, "", false},
++ {"https://git.openvibe.codes/api/v1/link/clone-requests/" + id, "", false},
++ {"https://git.openvibe.codes/elsewhere/" + id + "?token=" + token, "", false},
++ }
++ for _, c := range cases {
++ ep, err := ParseEndpoint(c.in)
++ if c.ok != (err == nil) {
++ t.Fatalf("%s: err=%v", c.in, err)
++ }
++ if err == nil && ep.Kind != c.kind {
++ t.Fatalf("%s: kind=%s", c.in, ep.Kind)
++ }
++ }
++}
++
++func TestRepoName(t *testing.T) {
++ if _, n, err := RepoName("openvibers/openvibe.codes"); err != nil || n != "openvibe.codes" {
++ t.Fatalf("name=%q err=%v", n, err)
++ }
++ for _, bad := range []string{"../x", "a/../b", "a/b.git", "a", "A/b", "a/b c", "/b"} {
++ if _, _, err := RepoName(bad); err == nil {
++ t.Errorf("%q was accepted", bad)
++ }
++ }
++}
++
++// gitRepo makes a small source repository with one commit and returns its folder and file:// URL.
++func gitRepo(t *testing.T) (string, string) {
++ t.Helper()
++ dir := t.TempDir()
++ run := func(args ...string) {
++ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
++ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=A", "[email protected]", "GIT_COMMITTER_NAME=A", "[email protected]")
++ if out, err := cmd.CombinedOutput(); err != nil {
++ t.Fatalf("git %v: %v %s", args, err, out)
++ }
++ }
++ run("init", "-q", "-b", "main")
++ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ run("add", "README.md")
++ run("commit", "-q", "-m", "first")
++ return dir, fileURL(dir)
++}
++
++type fakeGit struct {
++ mu sync.Mutex
++ request CloneRequest
++ posts []map[string]any
++ server *httptest.Server
++}
++
++func newFakeGit(t *testing.T, req CloneRequest, token string) *fakeGit {
++ f := &fakeGit{request: req}
++ mux := http.NewServeMux()
++ path := "/api/v1/link/clone-requests/" + req.ID
++ mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
++ if r.URL.Query().Get("token") != token {
++ http.Error(w, `{"code":"link.token","detail":"bad token"}`, 401)
++ return
++ }
++ switch r.Method {
++ case http.MethodGet:
++ f.mu.Lock()
++ defer f.mu.Unlock()
++ _ = json.NewEncoder(w).Encode(f.request)
++ case http.MethodPost:
++ var body map[string]any
++ _ = json.NewDecoder(r.Body).Decode(&body)
++ f.mu.Lock()
++ f.posts = append(f.posts, body)
++ f.mu.Unlock()
++ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
++ default:
++ http.Error(w, "no", 405)
++ }
++ })
++ f.server = httptest.NewServer(mux)
++ t.Cleanup(f.server.Close)
++ return f
++}
++
++func (f *fakeGit) link(req CloneRequest, token string) string {
++ return f.server.URL + "/api/v1/link/clone-requests/" + req.ID + "?token=" + token
++}
++
++func TestCloneApproved(t *testing.T) {
++ _, srcURL := gitRepo(t)
++ root := t.TempDir()
++ req := CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo", State: "pending",
++ CloneURL: srcURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(2 * time.Minute)}
++ token := strings.Repeat("b", 43)
++ f := newFakeGit(t, req, token)
++ res, err := Clone(context.Background(), f.link(req, token), CloneOptions{
++ Root: root, AllowLocal: true,
++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },
++ })
++ if err != nil {
++ t.Fatalf("clone: %v", err)
++ }
++ want, _ := filepath.EvalSymlinks(filepath.Join(root, "demo"))
++ if res.Folder != want && filepath.Base(res.Folder) != "demo" && res.Folder != filepath.Join(root, "demo") {
++ t.Fatalf("folder=%q want %q", res.Folder, want)
++ }
++ if b, err := os.ReadFile(filepath.Join(want, "README.md")); err != nil || strings.ReplaceAll(string(b), "\r\n", "\n") != "hello\n" {
++ t.Fatalf("clone contents: %q %v", b, err)
++ }
++ if len(f.posts) == 0 || f.posts[len(f.posts)-1]["state"] != "done" {
++ t.Fatalf("last post=%v", f.posts[len(f.posts)-1])
++ }
++}
++
++func TestCloneExpiredDoesNothing(t *testing.T) {
++ _, srcURL := gitRepo(t)
++ root := t.TempDir()
++ req := CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo", State: "pending",
++ CloneURL: srcURL, CreatedAt: time.Now().Add(-3 * time.Minute), ExpiresAt: time.Now().Add(-time.Minute)}
++ token := strings.Repeat("c", 43)
++ f := newFakeGit(t, req, token)
++ approved := false
++ _, err := Clone(context.Background(), f.link(req, token), CloneOptions{
++ Root: root, AllowLocal: true,
++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) {
++ approved = true
++ return CloneDecision{Approve: true}, nil
++ },
++ })
++ if err == nil || approved {
++ t.Fatalf("expired request: err=%v approved=%v", err, approved)
++ }
++ if _, err := os.Stat(filepath.Join(root, "demo")); !os.IsNotExist(err) {
++ t.Fatal("expired request made a folder")
++ }
++ if len(f.posts) == 0 || f.posts[0]["state"] != "refused" {
++ t.Fatalf("posts=%v", f.posts)
++ }
++}
++
++func TestCloneWithoutApprovalDoesNothing(t *testing.T) {
++ for name, approve := range map[string]func(context.Context, CloneRequest) (CloneDecision, error){
++ "no window to ask": nil,
++ "refused in Link": func(context.Context, CloneRequest) (CloneDecision, error) {
++ return CloneDecision{Approve: false}, nil
++ },
++ } {
++ t.Run(name, func(t *testing.T) {
++ _, srcURL := gitRepo(t)
++ root := t.TempDir()
++ req := CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo", State: "pending",
++ CloneURL: srcURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(2 * time.Minute)}
++ token := strings.Repeat("f", 43)
++ f := newFakeGit(t, req, token)
++ res, err := Clone(context.Background(), f.link(req, token), CloneOptions{Root: root, AllowLocal: true, Approve: approve})
++ if name == "no window to ask" {
++ if err == nil {
++ t.Fatal("a clone ran without anyone to ask")
++ }
++ } else if err != nil || res.Reason != "refused in Link" {
++ t.Fatalf("refused: %v %q", err, res.Reason)
++ }
++ if _, err := os.Stat(filepath.Join(root, "demo")); !os.IsNotExist(err) {
++ t.Fatal("something was written without approval")
++ }
++ if len(f.posts) == 0 || f.posts[len(f.posts)-1]["state"] != "refused" {
++ t.Fatalf("posts=%v", f.posts)
++ }
++ })
++ }
++}
++
++func TestCloneFolderOutsideRootRefused(t *testing.T) {
++ _, srcURL := gitRepo(t)
++ root := t.TempDir()
++ outside := filepath.Join(t.TempDir(), "elsewhere")
++ req := CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo", State: "pending",
++ CloneURL: srcURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(2 * time.Minute)}
++ token := strings.Repeat("d", 43)
++ f := newFakeGit(t, req, token)
++ _, err := Clone(context.Background(), f.link(req, token), CloneOptions{
++ Root: root, AllowLocal: true,
++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) {
++ return CloneDecision{Approve: true, Folder: outside}, nil
++ },
++ })
++ if err == nil {
++ t.Fatal("a folder outside the root was accepted")
++ }
++ if _, err := os.Stat(outside); !os.IsNotExist(err) {
++ t.Fatal("something was made outside the root")
++ }
++}
++
++func TestCloneNonEmptyNeedsSecondApproval(t *testing.T) {
++ _, srcURL := gitRepo(t)
++ root := t.TempDir()
++ folder := filepath.Join(root, "demo")
++ if err := os.MkdirAll(folder, 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(filepath.Join(folder, "keep.txt"), []byte("mine\n"), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ req := CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo", State: "pending",
++ CloneURL: srcURL, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(2 * time.Minute)}
++ token := strings.Repeat("e", 43)
++ f := newFakeGit(t, req, token)
++ _, err := Clone(context.Background(), f.link(req, token), CloneOptions{
++ Root: root, AllowLocal: true,
++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },
++ ApproveOverwrite: func(context.Context, CloneRequest, string) (bool, error) { return false, nil },
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ if b, _ := os.ReadFile(filepath.Join(folder, "keep.txt")); string(b) != "mine\n" {
++ t.Fatal("the folder was overwritten without the second approval")
++ }
++}
++
++func TestMachineBrowsing(t *testing.T) {
++ src, _ := gitRepo(t)
++ if err := os.MkdirAll(filepath.Join(src, "dir"), 0o700); err != nil {
++ t.Fatal(err)
++ }
++ repos, err := scanRepos(filepath.Dir(src), "git")
++ if err != nil || len(repos) == 0 {
++ t.Fatalf("repos=%v err=%v", repos, err)
++ }
++ root := filepath.Dir(src)
++ repoName := strings.ToLower(filepath.Base(src))
++ treeOut := answer(context.Background(), root, "git", MachineRequest{ID: "r1", Kind: "tree", Repo: repoName, Ref: "main"})
++ if treeOut["ok"] != true {
++ t.Fatalf("tree: %v", treeOut)
++ }
++ fileOut := answer(context.Background(), root, "git", MachineRequest{ID: "r2", Kind: "file", Repo: repoName, Ref: "main", Path: "README.md"})
++ if fileOut["ok"] != true {
++ t.Fatalf("file: %v", fileOut)
++ }
++ if f := fileOut["result"].(File); !strings.Contains(f.Text, "hello") {
++ t.Fatalf("file text=%q", f.Text)
++ }
++ logOut := answer(context.Background(), root, "git", MachineRequest{ID: "r3", Kind: "log", Repo: repoName, Ref: "main"})
++ if logOut["ok"] != true {
++ t.Fatalf("log: %v", logOut)
++ }
++ // A path that escapes the repository is refused, never read.
++ bad := answer(context.Background(), root, "git", MachineRequest{ID: "r4", Kind: "file", Repo: repoName, Ref: "main", Path: "../../etc/passwd"})
++ if bad["ok"] == true {
++ t.Fatal("a traversing path was read")
++ }
++}
ddiff --git a/internal/gitrelay/machine.go b/internal/gitrelay/machine.go
nnew file mode 100644
iindex 0000000..cac159c
---- /dev/null
++++ b/internal/gitrelay/machine.go
@@@ -0,0 +1,546 @@
++package gitrelay
++
++import (
++ "bytes"
++ "context"
++ "encoding/json"
++ "errors"
++ "io"
++ "log/slog"
++ "net/http"
++ "os"
++ "os/exec"
++ "path/filepath"
++ "regexp"
++ "runtime"
++ "sort"
++ "strconv"
++ "strings"
++ "time"
++)
++
++// MachineRepo is one repository under the root the person chose in Link, as the page lists it.
++type MachineRepo struct {
++ Name string `json:"name"`
++ Branch string `json:"branch"`
++ Head string `json:"head"`
++ HeadSubject string `json:"head_subject,omitempty"`
++ UpdatedAt time.Time `json:"updated_at,omitempty"`
++}
++
++// MachineOptions tune a published machine.
++type MachineOptions struct {
++ Root string
++ Name string // the computer's name; defaults to the host name
++ OS string // linux, darwin or windows; defaults to runtime.GOOS
++ Client *http.Client
++ Log *slog.Logger
++ GitBin string
++ // PollTimeout is how long Git may hold one watch; it is under the client's deadline.
++ PollTimeout time.Duration
++}
++
++// MachineRequest is one read-only question from the page, carried on the watch the machine opened.
++type MachineRequest struct {
++ ID string `json:"id"`
++ Kind string `json:"kind"` // tree, file or log
++ Repo string `json:"repo"`
++ Ref string `json:"ref,omitempty"`
++ Path string `json:"path,omitempty"`
++}
++
++// Watch is Git's answer to one watch.
++type Watch struct {
++ Online bool `json:"online"`
++ Request *MachineRequest `json:"request,omitempty"`
++}
++
++// MachineConnect publishes this computer's repositories, read-only, until ctx ends. The machine polls; Git can
++// never open a connection to it. A tree, file or log answer is sent back over the same poll.
++func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {
++ if o.Log == nil {
++ o.Log = slog.Default()
++ }
++ if o.GitBin == "" {
++ o.GitBin = "git"
++ }
++ if o.PollTimeout <= 0 {
++ o.PollTimeout = 25 * time.Second
++ }
++ ep, err := ParseEndpoint(raw)
++ if err != nil {
++ return err
++ }
++ if ep.Kind != "machine" {
++ return errors.New("that link is for a clone request, not a published machine")
++ }
++ root, err := prepareRoot(o.Root)
++ if err != nil {
++ return err
++ }
++ if o.Name == "" {
++ o.Name, _ = os.Hostname()
++ }
++ if o.Name == "" {
++ o.Name = "this computer"
++ }
++ if len([]rune(o.Name)) > 80 {
++ o.Name = string([]rune(o.Name)[:80])
++ }
++ if o.OS == "" {
++ o.OS = osName()
++ }
++ client := o.Client
++ if client == nil {
++ client = &http.Client{}
++ }
++ wait := 2 * time.Second
++ for {
++ repos, err := scanRepos(root, o.GitBin)
++ if err != nil {
++ o.Log.Warn("gitrelay: the root could not be read", "err", err)
++ repos = nil
++ }
++ if err := call(ctx, client, "POST", linkURL(ep, "/hello"), map[string]any{"name": o.Name, "os": o.OS, "repos": repos}, nil); err != nil {
++ if terminal(err) {
++ return err
++ }
++ o.Log.Warn("gitrelay: hello failed; trying again", "err", err)
++ if !sleep(ctx, wait) {
++ return ctx.Err()
++ }
++ wait = nextWait(wait)
++ continue
++ }
++ wait = 2 * time.Second
++ for ctx.Err() == nil {
++ pollCtx, cancel := context.WithTimeout(ctx, o.PollTimeout+10*time.Second)
++ var w Watch
++ err := call(pollCtx, client, "GET", linkURL(ep, "/watch"), nil, &w)
++ cancel()
++ if err != nil {
++ if ctx.Err() != nil {
++ return ctx.Err()
++ }
++ if terminal(err) {
++ return err
++ }
++ o.Log.Warn("gitrelay: watch failed; trying again", "err", err)
++ break
++ }
++ if w.Request == nil {
++ continue
++ }
++ body := answer(ctx, root, o.GitBin, *w.Request)
++ if err := call(ctx, client, "POST", linkURL(ep, "/results"), body, nil); err != nil {
++ if terminal(err) {
++ return err
++ }
++ o.Log.Warn("gitrelay: an answer was not delivered", "err", err)
++ }
++ }
++ if !sleep(ctx, wait) {
++ return ctx.Err()
++ }
++ wait = nextWait(wait)
++ }
++}
++
++func osName() string { return runtime.GOOS }
++
++func terminal(err error) bool {
++ var he *httpError
++ return errors.As(err, &he) && (he.Status == 401 || he.Status == 403 || he.Status == 404 || he.Status == 410)
++}
++
++func nextWait(d time.Duration) time.Duration {
++ d *= 2
++ if d > 30*time.Second {
++ return 30 * time.Second
++ }
++ return d
++}
++
++func sleep(ctx context.Context, d time.Duration) bool {
++ t := time.NewTimer(d)
++ defer t.Stop()
++ select {
++ case <-ctx.Done():
++ return false
++ case <-t.C:
++ return true
++ }
++}
++
++// scanRepoPaths lists the folders under the root that are a repository (a .git directory or file), at the top
++// level and one level down, with the path used to read it. Only the name, branch, head and date are sent.
++func scanRepoPaths(root, gitBin string) []struct {
++ MachineRepo
++ Path string
++} {
++ var out []struct {
++ MachineRepo
++ Path string
++ }
++ seen := map[string]bool{}
++ var walk func(dir string, depth int)
++ walk = func(dir string, depth int) {
++ entries, err := os.ReadDir(dir)
++ if err != nil {
++ return
++ }
++ for _, e := range entries {
++ if !e.IsDir() {
++ continue
++ }
++ name := strings.ToLower(e.Name())
++ if strings.HasPrefix(name, ".") || seen[name] {
++ continue
++ }
++ full := filepath.Join(dir, e.Name())
++ if isRepo(full) {
++ if r, ok := readRepo(gitBin, full, name); ok {
++ seen[name] = true
++ out = append(out, struct {
++ MachineRepo
++ Path string
++ }{r, full})
++ }
++ continue
++ }
++ if depth > 0 {
++ walk(full, depth-1)
++ }
++ }
++ }
++ walk(root, 1)
++ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
++ if len(out) > 500 {
++ out = out[:500]
++ }
++ return out
++}
++
++func scanRepos(root, gitBin string) ([]MachineRepo, error) {
++ scanned := scanRepoPaths(root, gitBin)
++ out := make([]MachineRepo, 0, len(scanned))
++ for _, s := range scanned {
++ out = append(out, s.MachineRepo)
++ }
++ return out, nil
++}
++
++func isRepo(dir string) bool {
++ fi, err := os.Stat(filepath.Join(dir, ".git"))
++ return err == nil && (fi.IsDir() || fi.Mode().IsRegular())
++}
++
++var machineRepoNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}$`)
++
++func readRepo(gitBin, dir, name string) (MachineRepo, bool) {
++ if !machineRepoNameRe.MatchString(name) || strings.HasSuffix(name, ".git") {
++ return MachineRepo{}, false
++ }
++ branch, ok := gitOut(gitBin, dir, "rev-parse", "--abbrev-ref", "HEAD")
++ if !ok {
++ return MachineRepo{}, false
++ }
++ head, ok := gitOut(gitBin, dir, "rev-parse", "HEAD")
++ if !ok {
++ return MachineRepo{}, false
++ }
++ r := MachineRepo{Name: name, Branch: strings.TrimSpace(branch), Head: strings.TrimSpace(head)}
++ if line, ok := gitOut(gitBin, dir, "log", "-1", "--format=%s%x00%cI", "HEAD"); ok {
++ parts := strings.SplitN(line, "\x00", 2)
++ r.HeadSubject = short(parts[0])
++ if len(parts) == 2 {
++ r.UpdatedAt, _ = time.Parse(time.RFC3339, strings.TrimSpace(parts[1]))
++ }
++ }
++ return r, true
++}
++
++func gitOut(gitBin, dir string, args ...string) (string, bool) {
++ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
++ defer cancel()
++ cmd := exec.CommandContext(ctx, gitBin, append([]string{"-C", dir}, args...)...)
++ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
++ var out, errb bytes.Buffer
++ cmd.Stdout, cmd.Stderr = &out, &errb
++ if err := cmd.Run(); err != nil || out.Len() > 256*1024 {
++ return "", false
++ }
++ return out.String(), true
++}
++
++func repoPath(root string, repos map[string]string, name string) (string, error) {
++ if !machineRepoNameRe.MatchString(name) {
++ return "", errors.New("not a repository on this computer")
++ }
++ p, ok := repos[name]
++ if !ok {
++ return "", errors.New("not a repository on this computer")
++ }
++ return p, nil
++}
++
++func answer(ctx context.Context, root, gitBin string, req MachineRequest) map[string]any {
++ repos := map[string]string{}
++ for _, s := range scanRepoPaths(root, gitBin) {
++ repos[s.Name] = s.Path
++ }
++ out := map[string]any{"request_id": req.ID, "ok": false}
++ p, err := repoPath(root, repos, req.Repo)
++ if err != nil {
++ out["error"] = err.Error()
++ return out
++ }
++ ref, err := validRef(req.Ref)
++ if err != nil {
++ out["error"] = err.Error()
++ return out
++ }
++ switch req.Kind {
++ case "tree":
++ rel, err := validRel(req.Path)
++ if err != nil {
++ out["error"] = err.Error()
++ return out
++ }
++ entries, err := tree(gitBin, p, ref, rel)
++ if err != nil {
++ out["error"] = short(err.Error())
++ return out
++ }
++ out["ok"], out["result"] = true, map[string]any{"ref": ref, "path": rel, "entries": entries}
++ case "file":
++ rel, err := validRel(req.Path)
++ if err != nil || rel == "" {
++ out["error"] = "a file path is required"
++ return out
++ }
++ blob, err := file(gitBin, p, ref, rel)
++ if err != nil {
++ out["error"] = short(err.Error())
++ return out
++ }
++ out["ok"], out["result"] = true, blob
++ case "log":
++ rel, err := validRel(req.Path)
++ if err != nil {
++ out["error"] = err.Error()
++ return out
++ }
++ log, err := history(gitBin, p, ref, rel)
++ if err != nil {
++ out["error"] = short(err.Error())
++ return out
++ }
++ out["ok"], out["result"] = true, map[string]any{"ref": ref, "commits": log}
++ default:
++ out["error"] = "unknown request"
++ }
++ return out
++}
++
++var refRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$`)
++
++func validRef(ref string) (string, error) {
++ ref = strings.TrimSpace(ref)
++ if ref == "" {
++ return "", nil
++ }
++ if !refRe.MatchString(ref) || strings.Contains(ref, "..") || strings.Contains(ref, "@{") || strings.HasSuffix(ref, ".") {
++ return "", errors.New("not a valid ref")
++ }
++ return ref, nil
++}
++
++// validRel accepts a repository-relative path only: no leading slash, no .., no control characters.
++func validRel(p string) (string, error) {
++ p = strings.TrimPrefix(strings.TrimSpace(p), "./")
++ if p == "" {
++ return "", nil
++ }
++ if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") || strings.Contains(p, "\x00") || len(p) > 4096 {
++ return "", errors.New("not a path inside the repository")
++ }
++ for _, seg := range strings.Split(p, "/") {
++ if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, "-") {
++ return "", errors.New("not a path inside the repository")
++ }
++ }
++ return p, nil
++}
++
++// TreeEntry is one row of a machine repository's tree.
++type TreeEntry struct {
++ Name string `json:"name"`
++ Path string `json:"path"`
++ Type string `json:"type"`
++ Size *int64 `json:"size,omitempty"`
++}
++
++func tree(gitBin, dir, ref, path string) ([]TreeEntry, error) {
++ args := []string{"-C", dir, "ls-tree", "-z", "-l"}
++ if ref != "" {
++ args = append(args, ref)
++ } else {
++ args = append(args, "HEAD")
++ }
++ args = append(args, "--")
++ if path != "" {
++ args = append(args, path)
++ }
++ cmd := exec.Command(gitBin, args...)
++ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
++ out, err := cmd.Output()
++ if err != nil {
++ return nil, errors.New("git could not read the tree")
++ }
++ var entries []TreeEntry
++ for _, rec := range bytes.Split(out, []byte{0}) {
++ if len(rec) == 0 {
++ continue
++ }
++ // <mode> <type> <sha> <size>\t<name>
++ tab := bytes.IndexByte(rec, '\t')
++ if tab < 0 {
++ continue
++ }
++ meta := strings.Fields(string(rec[:tab]))
++ if len(meta) < 4 {
++ continue
++ }
++ name := string(rec[tab+1:])
++ if name == "" {
++ continue
++ }
++ typ := meta[1]
++ if typ == "commit" {
++ typ = "commit"
++ }
++ e := TreeEntry{Name: filepath.Base(name), Path: name, Type: typ}
++ if typ == "blob" && meta[3] != "-" {
++ if n, err := strconv.ParseInt(meta[3], 10, 64); err == nil {
++ e.Size = &n
++ }
++ }
++ entries = append(entries, e)
++ }
++ sort.Slice(entries, func(i, j int) bool {
++ if entries[i].Type != entries[j].Type {
++ return entries[i].Type == "tree"
++ }
++ return entries[i].Name < entries[j].Name
++ })
++ return entries, nil
++}
++
++// File is a machine repository's file, with the same shape Git's own blob view uses.
++type File struct {
++ Path string `json:"path"`
++ Binary bool `json:"binary"`
++ Size int64 `json:"size"`
++ Text string `json:"text,omitempty"`
++ Truncated bool `json:"truncated,omitempty"`
++}
++
++const maxFileBytes = 2 * 1024 * 1024
++
++func file(gitBin, dir, ref, path string) (File, error) {
++ obj := "HEAD:" + path
++ if ref != "" {
++ obj = ref + ":" + path
++ }
++ sizeOut, ok := gitOut(gitBin, dir, "cat-file", "-s", obj)
++ if !ok {
++ return File{}, errors.New("no such file at that ref")
++ }
++ size, _ := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64)
++ f := File{Path: path, Size: size}
++ if size > maxFileBytes {
++ f.Truncated = true
++ return f, nil
++ }
++ b, ok := gitBytes(gitBin, dir, maxFileBytes+1, "cat-file", "blob", obj)
++ if !ok {
++ return File{}, errors.New("git could not read the file")
++ }
++ if len(b) > maxFileBytes {
++ f.Truncated = true
++ return f, nil
++ }
++ f.Text = string(b)
++ if bytes.IndexByte(b, 0) >= 0 {
++ f.Binary, f.Text = true, ""
++ }
++ return f, nil
++}
++
++// gitBytes runs git and returns at most limit bytes of stdout, so a large blob never fills memory.
++func gitBytes(gitBin, dir string, limit int, args ...string) ([]byte, bool) {
++ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
++ defer cancel()
++ cmd := exec.CommandContext(ctx, gitBin, append([]string{"-C", dir}, args...)...)
++ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
++ stdout, err := cmd.StdoutPipe()
++ if err != nil {
++ return nil, false
++ }
++ cmd.Stderr = io.Discard
++ if err := cmd.Start(); err != nil {
++ return nil, false
++ }
++ b, _ := io.ReadAll(io.LimitReader(stdout, int64(limit)))
++ _ = cmd.Wait()
++ return b, true
++}
++
++// LogEntry is one commit of a machine repository's history.
++type LogEntry struct {
++ SHA string `json:"sha"`
++ Short string `json:"short"`
++ Subject string `json:"subject"`
++ Message string `json:"message"`
++ Author string `json:"author"`
++ Date string `json:"date"`
++}
++
++func history(gitBin, dir, ref, path string) ([]LogEntry, error) {
++ args := []string{"-C", dir, "log", "-n", "60", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"}
++ if ref != "" {
++ args = append(args, ref)
++ } else {
++ args = append(args, "HEAD")
++ }
++ args = append(args, "--")
++ if path != "" {
++ args = append(args, path)
++ }
++ cmd := exec.Command(gitBin, args...)
++ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
++ out, err := cmd.Output()
++ if err != nil {
++ return nil, errors.New("git could not read the history")
++ }
++ var commits []LogEntry
++ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
++ if line == "" {
++ continue
++ }
++ f := strings.Split(line, "\x1f")
++ if len(f) < 5 {
++ continue
++ }
++ commits = append(commits, LogEntry{SHA: f[0], Short: f[1], Subject: f[2], Author: f[3], Date: f[4]})
++ }
++ return commits, nil
++}
++
++// decodeResult turns a result body back into the typed shape; it is exported for tests and for a future panel.
++func decodeResult[T any](raw json.RawMessage) (T, error) {
++ var v T
++ err := json.Unmarshal(raw, &v)
++ return v, err
++}