openvibers/openvibe.link Commits
Git on this computer: clone with approval, and a published machine (#148)
Changes
@@@ -129,6 +129,14 @@ Dock's activity read-only, and **Send to OpenVibe** from any app's share sheet topenvibe.bot/link/phone and shows up as a device of kind `phone`. No phone automation at all in this version: noAccessibilityService, 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 driverprotocol, 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),@@@ -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)",nnew file mode 100644@@@ -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++}@@@ -104,6 +104,10 @@ Usage:behind OpenVibe sign-in, until Ctrl-Copenvibe-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 versionGlobal flags (before or after the command):@@@ -206,6 +210,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intshPort = 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) intreturn 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) {nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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")++ }++}nnew file mode 100644@@@ -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++}
@@ -129,6 +129,14 @@ Dock's activity read-only, and **Send to OpenVibe** from any app's share sheet t
@@ -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
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).
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
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),
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),
[panel](docs/panel.md) (Link's own window and its security rules),
@@ -5,13 +5,14 @@
@@ -5,13 +5,14 @@
"wave": "T15",
"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)",
"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",
"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",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"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.",
"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": [
"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",
"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)",
"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)",
"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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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
+}
@@ -104,6 +104,10 @@ Usage:
@@ -104,6 +104,10 @@ Usage:
behind OpenVibe sign-in, until Ctrl-C
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 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 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
openvibe-link version
Global flags (before or after the command):
Global flags (before or after the command):
@@ -206,6 +210,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -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)")
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)")
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)")
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.
// Accept flags anywhere: split positionals out.
var positional []string
var positional []string
@@ -297,6 +303,18 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -297,6 +303,18 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 2
return 2
}
}
err = cmdProfileVerify(g, positional[1], *server, *keyID, stdout)
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":
case "share":
err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)
err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)
if errors.Is(err, errUsage) {
if errors.Is(err, errUsage) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,381 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,291 @@
@@ -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")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,546 @@
@@ -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
+}