From c4d83b0007894df2a2463aa7440580a99d27e1ac Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Fri, 11 Sep 2026 12:46:31 +0000 Subject: [PATCH] gitrepo: serve immutable Git checkouts over NFS This commit adds the ability to serve an immutable, shallow checkout of a git repository at a specific commit over NFS. It includes some minimal git metadata so that the git CLI can run read-only commands much like any other shallow checkout. Callers acquire a checkout before mounting /repos// and release it after the guest stops. Concurrent guests share an export until its last lease is released. The checkout is read-only, including its synthetic Git metadata. The index assumes tracked files are unchanged, so this view is not suitable as the backing filesystem for a writable overlay. The backing storage is a bare git repo on disk per configured repository. This is preferred over packing all git repos into the same git storage that the existing gitstore uses for 2 main reasons; firstly the methods required of each store are fairly specialised to their use case, and secondly, I wanted to avoid moving into "interesting" territory for git storage. A store per repo keeps repository data separate for isolation and keeps us on the happy path for the kinds of use cases that git storage is generally well optimised for. Git repository NFS handles are stable SHA-256 hashes of the repository name, commit, and path. Their reverse mappings are kept in memory until the checkout's last lease is released. Unlike the go mod implementation, these mappings are not reconstructed on a cache miss: after release or restart, an old handle remains stale until the checkout is acquired and the path's mapping is registered again. This keeps checkout state bounded by active leases without coupling file identity to a lease's lifetime. The nfsexports package implements a multiplexer to allow us to serve multiple exports on the same NFS server, keeping compatibility with clients that rely on rpcbind. It carves out a namespace for the existing go mod NFS handles (64 bytes long), and a schema for adding other namespaces alongside the new git repo handle namespace. Signed-off-by: Tom Proctor --- .github/workflows/test.yml | 8 + cmd/gomodfs/gomodfs-main.go | 52 +++- gitrepo/checkout.go | 442 +++++++++++++++++++++++++++ gitrepo/gitrepo.go | 212 +++++++++++++ gitrepo/gitrepo_test.go | 378 +++++++++++++++++++++++ gitrepo/nfs.go | 334 ++++++++++++++++++++ gitrepo/nfs_test.go | 250 +++++++++++++++ gitrepo/repository.go | 95 ++++++ nfsexports/nfs.go | 176 +++++++++++ nfsexports/nfs_test.go | 290 ++++++++++++++++++ testing/ci/ci_test.go | 100 +++++- testing/nfsmount/nfsmount.go | 44 ++- testing/startgomodfs/fixture.go | 59 ++++ testing/startgomodfs/startgomodfs.go | 20 ++ 14 files changed, 2446 insertions(+), 14 deletions(-) create mode 100644 gitrepo/checkout.go create mode 100644 gitrepo/gitrepo.go create mode 100644 gitrepo/gitrepo_test.go create mode 100644 gitrepo/nfs.go create mode 100644 gitrepo/nfs_test.go create mode 100644 gitrepo/repository.go create mode 100644 nfsexports/nfs.go create mode 100644 nfsexports/nfs_test.go create mode 100644 testing/startgomodfs/fixture.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9e6b40..c2433b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,6 +76,14 @@ jobs: run: | go run ./testing/nfsmount + - name: "mount repository NFS" + run: | + go run ./testing/nfsmount -repo-export-file=gomodfs-repo-export + + - name: "verify repository mount" + run: | + go test -v ./testing/ci -repository-mount + - name: "[unix] list GOMODCACHE directory" if: runner.os == 'Linux' || runner.os == 'macOS' run: | diff --git a/cmd/gomodfs/gomodfs-main.go b/cmd/gomodfs/gomodfs-main.go index e9d95e8..a6df2ae 100644 --- a/cmd/gomodfs/gomodfs-main.go +++ b/cmd/gomodfs/gomodfs-main.go @@ -7,21 +7,27 @@ package main import ( + "context" "flag" + "fmt" "log" "net" "net/http" "net/http/pprof" + "net/url" "os" "os/exec" "path/filepath" "runtime" + "strings" "github.com/bradfitz/parentdeath" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/tailscale/gomodfs" + "github.com/tailscale/gomodfs/gitrepo" + "github.com/tailscale/gomodfs/nfsexports" "github.com/tailscale/gomodfs/stats" "github.com/tailscale/gomodfs/store/gitstore" "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" @@ -34,6 +40,8 @@ var ( flagNFS = flag.String("nfs", "", "if set, listen on this port for NFS requests") flagMountPoint = flag.String("mount", "", "if set, mount the filesystem at this path") flagMemLimitMB = flag.Int64("mem-limit-mb", 0, "how many megabytes (MiB) of memory gomodfs can use to store file contents in memory; 0 means to use a default") + flagRepo = flag.String("repo", "", "experimental: Git repository URL to export as /repos/// over NFS. The and are inferred from the last 2 slash-separated segments") + flagCommit = flag.String("commit", "", "experimental: full commit SHA to export from -repo") portmapper = flag.Bool("portmapper", false, "if set, run rpcbind portmapper on TCP+UDP port 111 (needed for Windows NFS clients). For NFS mode only") flagWinFSP = flag.Bool("winfsp", false, "if set, use WinFSP on Windows") @@ -41,6 +49,19 @@ var ( flagNFSForWindows = flag.Bool("nfs-for-windows-clients", runtime.GOOS == "windows", "if set, alter NFS server behavior for Windows clients (TODO: ideally auto-detect and remove this flag)") ) +func repositoryName(remote string) (string, error) { + u, err := url.Parse(remote) + if err != nil { + return "", fmt.Errorf("invalid -repo URL %q: %w", remote, err) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "", fmt.Errorf("invalid -repo URL %q; want a path ending in owner/repo", remote) + } + parts[len(parts)-1] = strings.TrimSuffix(parts[len(parts)-1], ".git") + return strings.Join(parts[len(parts)-2:], "/"), nil +} + func main() { flag.Parse() @@ -97,7 +118,36 @@ func main() { } } - nfsHandler := mfs.NFSHandler() + var nfsHandler nfs.Handler = mfs.NFSHandler() + if *flagRepo != "" || *flagCommit != "" { + if *flagNFS == "" || *flagRepo == "" || *flagCommit == "" { + log.Fatal("-repo and -commit require each other and -nfs") + } + name, err := repositoryName(*flagRepo) + if err != nil { + log.Fatal(err) + } + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("os.UserHomeDir: %v", err) + } + manager := gitrepo.NewManager(filepath.Join(homeDir, ".cache", "gomodfs-repos"), map[string]gitrepo.Config{ + name: { + RemoteURL: *flagRepo, + }, + }) + manager.RegisterMetrics(reg) + gfs, err := gitrepo.NewNFSHandler(manager, 0, 0) + if err != nil { + log.Fatal(err) + } + release, err := gfs.Acquire(context.Background(), name, *flagCommit) + if err != nil { + log.Fatal(err) + } + defer release() + nfsHandler = nfsexports.New(mfs, gfs) + } if *debugListen != "" { ln, err := net.Listen("tcp", *debugListen) diff --git a/gitrepo/checkout.go b/gitrepo/checkout.go new file mode 100644 index 0000000..4274074 --- /dev/null +++ b/gitrepo/checkout.go @@ -0,0 +1,442 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bytes" + "compress/zlib" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "strconv" + "strings" + "sync" + "time" +) + +// staticTime is reported for immutable checkout files. +var staticTime = time.Date(2009, 11, 12, 13, 14, 15, 0, time.UTC) + +// checkout is an immutable, shallow filesystem rooted at one commit. +// Its Git metadata supports single-revision reads, not history, tags or remotes. +// The index assumes tracked files are unchanged, so this view isn't suitable +// for writable overlays. +type checkout struct { + repo *repository + commit string + + mu sync.Mutex + treeEnts map[string][]dirent + index []byte +} + +// dirent is an entry in an immutable checkout directory. +type dirent struct { + name string + mode fs.FileMode + size int64 + sha string +} + +func (c *checkout) gitDirEnts(p string) ([]dirent, error) { + switch p { + case ".git": + return []dirent{ + { + name: "HEAD", + mode: 0444, + size: 41, + }, + { + name: "config", + mode: 0444, + size: int64(len(checkoutConfig)), + }, + { + name: "index", + mode: 0444, + }, + { + name: "objects", + mode: fs.ModeDir | 0555, + }, + { + name: "refs", + mode: fs.ModeDir | 0555, + }, + { + name: "shallow", + mode: 0444, + size: 41, + }, + }, nil + case ".git/objects": + ret := []dirent{ + { + name: "info", + mode: fs.ModeDir | 0555, + }, + { + name: "pack", + mode: fs.ModeDir | 0555, + }, + } + for i := range 256 { + ret = append(ret, dirent{ + name: fmt.Sprintf("%02x", i), + mode: fs.ModeDir | 0555, + }) + } + return ret, nil + case ".git/refs": + return []dirent{ + { + name: "heads", + mode: fs.ModeDir | 0555, + }, + }, nil + case ".git/objects/info", ".git/objects/pack", ".git/refs/heads": + return nil, nil + } + if strings.HasPrefix(p, ".git/objects/") && len(strings.TrimPrefix(p, ".git/objects/")) == 2 { + return nil, nil // object fanout is intentionally not enumerable + } + return nil, os.ErrNotExist +} + +// readDir returns the entries in directory p. +func (c *checkout) readDir(ctx context.Context, p string) ([]dirent, error) { + p, ok := cleanPath(p) + if !ok { + return nil, os.ErrNotExist + } + if p == ".git" || strings.HasPrefix(p, ".git/") { + return c.gitDirEnts(p) + } + c.mu.Lock() + if e, ok := c.treeEnts[p]; ok { + ret := append([]dirent(nil), e...) + c.mu.Unlock() + return ret, nil + } + c.mu.Unlock() + spec := c.commit + "^{tree}" + if p != "" { + spec = c.commit + ":" + p + // A gitlink appears as a directory in an uninitialized worktree, but + // its object is a commit rather than a tree and must not be traversed. + if typ, err := c.repo.command(ctx, "cat-file", "-t", spec).Output(); err == nil && strings.TrimSpace(string(typ)) == "commit" { + return nil, nil + } + } + out, err := c.repo.command(ctx, "ls-tree", "-z", spec).Output() + if err != nil { + return nil, os.ErrNotExist + } + var ret []dirent + for line := range bytes.SplitSeq(out, []byte{0}) { + if len(line) == 0 { + continue + } + meta, name, ok := bytes.Cut(line, []byte{'\t'}) + if !ok { + return nil, fmt.Errorf("malformed ls-tree output") + } + f := strings.Fields(string(meta)) + if len(f) != 3 { + return nil, fmt.Errorf("malformed ls-tree metadata %q", meta) + } + size := int64(0) + mode := fileMode(f[0]) + if !mode.IsDir() { + size = -1 // resolved lazily; tree objects don't contain blob sizes + } + ret = append(ret, dirent{ + name: string(name), + mode: mode, + size: size, + sha: f[2], + }) + } + if p == "" { + ret = append(ret, dirent{ + name: ".git", + mode: fs.ModeDir | 0555, + }) + } + c.mu.Lock() + c.treeEnts[p] = append([]dirent(nil), ret...) + c.mu.Unlock() + return ret, nil +} + +func (c *checkout) lookup(ctx context.Context, p string) (dirent, error) { + p, ok := cleanPath(p) + if !ok { + return dirent{}, os.ErrNotExist + } + if p == "" { + return dirent{ + mode: fs.ModeDir | 0555, + }, nil + } + parent, base := path.Split(p) + parent = strings.TrimSuffix(parent, "/") + ents, err := c.readDir(ctx, parent) + if err != nil { + return dirent{}, err + } + for _, e := range ents { + if e.name == base { + return e, nil + } + } + // Virtual loose objects are looked up directly and aren't enumerable. + if obj, ok := objectPath(p); ok { + data, err := c.looseObject(ctx, obj) + if err != nil { + return dirent{}, err + } + return dirent{ + name: base, + mode: 0444, + size: int64(len(data)), + sha: obj, + }, nil + } + return dirent{}, os.ErrNotExist +} + +// stat returns metadata for p without following symbolic links. +func (c *checkout) stat(ctx context.Context, p string) (fs.FileInfo, error) { + p, ok := cleanPath(p) + if !ok { + return nil, os.ErrNotExist + } + e, err := c.lookup(ctx, p) + if err != nil { + return nil, err + } + if p == ".git/index" { + index, err := c.indexFile(ctx) + if err != nil { + return nil, err + } + e.size = int64(len(index)) + } + if e.size < 0 { + out, err := c.repo.command(ctx, "cat-file", "-s", e.sha).Output() + if err != nil { + return nil, os.ErrNotExist + } + e.size, err = strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) + if err != nil { + return nil, err + } + } + return info{ + e: e, + mod: staticTime, + }, nil +} + +// readlink returns the target of the symbolic link at p. +func (c *checkout) readlink(ctx context.Context, p string) (string, error) { + e, err := c.lookup(ctx, p) + if err != nil { + return "", err + } + if e.mode&fs.ModeSymlink == 0 { + return "", fs.ErrInvalid + } + b, err := c.object(ctx, e.sha) + return string(b), err +} + +const checkoutConfig = `[core] + repositoryformatversion = 0 + filemode = true + bare = false + checkstat = minimal +[gc] + auto = 0 +` + +func objectPath(p string) (string, bool) { + s, ok := strings.CutPrefix(p, ".git/objects/") + if !ok || len(s) != 41 || s[2] != '/' { + return "", false + } + obj := s[:2] + s[3:] + return obj, validSHA1(obj) +} + +// readFile returns the contents of the regular file at p. +func (c *checkout) readFile(ctx context.Context, p string) ([]byte, error) { + p, ok := cleanPath(p) + if !ok { + return nil, os.ErrNotExist + } + switch p { + case ".git/HEAD", ".git/shallow": + return []byte(c.commit + "\n"), nil + case ".git/config": + return []byte(checkoutConfig), nil + case ".git/index": + return c.indexFile(ctx) + } + if obj, ok := objectPath(p); ok { + return c.looseObject(ctx, obj) + } + e, err := c.lookup(ctx, p) + if err != nil { + return nil, err + } + if e.mode.IsDir() { + return nil, fmt.Errorf("%w: %s", errors.New("is a directory"), p) + } + return c.object(ctx, e.sha) +} + +func (c *checkout) object(ctx context.Context, sha string) (out []byte, retErr error) { + defer func() { + result := "success" + if retErr != nil { + result = "error" + } + c.repo.mgr.metrics.objectReads.WithLabelValues(c.repo.name, result).Inc() + }() + typ, err := c.repo.command(ctx, "cat-file", "-t", sha).Output() + if err != nil { + return nil, os.ErrNotExist + } + out, err = c.repo.command(ctx, "cat-file", string(bytes.TrimSpace(typ)), sha).Output() + if err != nil { + return nil, os.ErrNotExist + } + return out, nil +} + +// looseObject returns the Git object named by sha in loose-object format. +func (c *checkout) looseObject(ctx context.Context, sha string) ([]byte, error) { + if !validSHA1(sha) { + return nil, os.ErrNotExist + } + typ, err := c.repo.command(ctx, "cat-file", "-t", sha).Output() + if err != nil { + return nil, os.ErrNotExist + } + data, err := c.repo.command(ctx, "cat-file", string(bytes.TrimSpace(typ)), sha).Output() + if err != nil { + return nil, os.ErrNotExist + } + var b bytes.Buffer + zw := zlib.NewWriter(&b) + fmt.Fprintf(zw, "%s %d\x00", bytes.TrimSpace(typ), len(data)) + zw.Write(data) + zw.Close() + return b.Bytes(), nil +} + +// indexFile returns a synthesized Git index for the checkout tree. It marks +// tracked files assume-unchanged because filling in stat data would require +// fetching every promised blob. +func (c *checkout) indexFile(ctx context.Context) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.index != nil { + return append([]byte(nil), c.index...), nil + } + f, err := os.CreateTemp(c.repo.dir, "index-*") + if err != nil { + return nil, fmt.Errorf("creating checkout index: %w", err) + } + name := f.Name() + defer os.Remove(name) + if err := f.Close(); err != nil { + return nil, err + } + if err := os.Remove(name); err != nil { + return nil, err + } + command := func(args ...string) *exec.Cmd { + cmd := c.repo.command(ctx, args...) + cmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+name, "GIT_INDEX_VERSION=2") + return cmd + } + if err := run(command("read-tree", c.commit+"^{tree}"), "generating checkout index"); err != nil { + return nil, err + } + paths, err := command("ls-files", "-z").Output() + if err != nil { + return nil, fmt.Errorf("listing checkout index: %w", err) + } + // update-index requires a worktree even though marking entries doesn't + // inspect it. All paths come from our temporary index, not the host. + cmd := command("--work-tree=.", "update-index", "--assume-unchanged", "-z", "--stdin") + cmd.Stdin = bytes.NewReader(paths) + if err := run(cmd, "marking immutable index entries"); err != nil { + return nil, err + } + idx, err := os.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading checkout index: %w", err) + } + c.index = idx + return append([]byte(nil), idx...), nil +} + +type info struct { + e dirent + mod time.Time +} + +func (i info) Name() string { return i.e.name } +func (i info) Size() int64 { return i.e.size } +func (i info) Mode() fs.FileMode { return i.e.mode } +func (i info) ModTime() time.Time { return i.mod } +func (i info) IsDir() bool { return i.e.mode.IsDir() } +func (i info) Sys() any { return nil } + +func validSHA1(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if c < '0' || c > '9' && c < 'a' || c > 'f' { + return false + } + } + return true +} + +func cleanPath(p string) (string, bool) { + p = strings.TrimPrefix(p, "/") + if p == "" { + return "", true + } + if strings.ContainsRune(p, '\x00') || path.Clean(p) != p || strings.HasPrefix(p, "../") { + return "", false + } + return p, true +} + +func fileMode(gitMode string) fs.FileMode { + switch gitMode { + case "040000": + return fs.ModeDir | 0555 + case "100755": + return 0555 + case "120000": + return fs.ModeSymlink | 0444 + case "160000": + return fs.ModeDir | 0555 + default: + return 0444 + } +} diff --git a/gitrepo/gitrepo.go b/gitrepo/gitrepo.go new file mode 100644 index 0000000..ad7aac4 --- /dev/null +++ b/gitrepo/gitrepo.go @@ -0,0 +1,212 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package gitrepo provides immutable filesystem views of commits stored in +// repository-scoped bare, blobless Git mirrors. +package gitrepo + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/prometheus/client_golang/prometheus" +) + +// Config describes a repository that the manager may serve. +type Config struct { + // RemoteURL is the Git remote used to populate the local bare mirror. + RemoteURL string +} + +// Manager owns one bare repository for each configured upstream. +type Manager struct { + root string + repos map[string]Config + + mu sync.Mutex + open map[string]*repository + metrics *metrics +} + +// metrics contains repository operations with bounded labels. Commit hashes +// and paths are deliberately not labels. +type metrics struct { + fetches *prometheus.CounterVec + fetchDuration *prometheus.HistogramVec + objectReads *prometheus.CounterVec +} + +// NewManager returns a manager backed by root for the configured repositories. +func NewManager(root string, repos map[string]Config) *Manager { + return &Manager{ + root: root, + repos: repos, + metrics: &metrics{ + fetches: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "gomodfs_repo_fetch_total", + Help: "repository commit fetches by result.", + }, []string{"repo", "result"}), + fetchDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "gomodfs_repo_fetch_duration_seconds", + Help: "repository commit fetch duration.", + Buckets: prometheus.DefBuckets, + }, []string{"repo"}), + objectReads: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "gomodfs_repo_object_read_total", + Help: "repository object reads by result.", + }, []string{"repo", "result"}), + }, + } +} + +// RegisterMetrics registers metrics for this manager. +func (m *Manager) RegisterMetrics(reg prometheus.Registerer) { + reg.MustRegister(m.metrics.fetches, m.metrics.fetchDuration, m.metrics.objectReads) +} + +// repository opens or creates the local bare mirror for name. +func (m *Manager) repository(ctx context.Context, name string) (*repository, error) { + cfg, ok := m.repos[name] + if !ok { + return nil, os.ErrNotExist + } + if !validName(name) { + return nil, fmt.Errorf("invalid repository name %q", name) + } + // Concurrent initialization also rewrites config in an existing mirror. + // Serialize openers so they don't contend for Git's config lock. + m.mu.Lock() + defer m.mu.Unlock() + if r := m.open[name]; r != nil { + return r, nil + } + + dir := filepath.Join(m.root, filepath.FromSlash(name)+".git") + if err := m.initializeRepoLocked(ctx, name, dir, cfg); err != nil { + return nil, err + } + if m.open == nil { + m.open = map[string]*repository{} + } + m.open[name] = &repository{ + name: name, + dir: dir, + cfg: cfg, + mgr: m, + } + return m.open[name], nil +} + +// checkout returns an immutable filesystem view of commit in name. +func (m *Manager) checkout(ctx context.Context, name, commit string) (*checkout, error) { + if !validSHA1(commit) { + return nil, fmt.Errorf("invalid full commit SHA %q", commit) + } + r, err := m.repository(ctx, name) + if err != nil { + return nil, err + } + if err := r.ensureCommit(ctx, commit); err != nil { + return nil, err + } + return &checkout{ + repo: r, + commit: commit, + treeEnts: map[string][]dirent{}, + }, nil +} + +// initializeRepoLocked initializes the repository at dir if it does not already +// exist. m.mu must be held. +func (m *Manager) initializeRepoLocked(ctx context.Context, name, dir string, cfg Config) error { + r := &repository{ + name: name, + dir: dir, + cfg: cfg, + } + if fi, err := os.Stat(dir); err == nil { + if !fi.IsDir() { + return fmt.Errorf("repository path %q is not a directory", dir) + } + out, err := r.command(ctx, "rev-parse", "--is-bare-repository").Output() + if err != nil || strings.TrimSpace(string(out)) != "true" { + return fmt.Errorf("repository path %q is not a bare Git repository", dir) + } + return r.configure(ctx, false) + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(dir), 0755); err != nil { + return err + } + tmp, err := os.MkdirTemp(filepath.Dir(dir), ".repo-init-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + r.dir = tmp + + if err := run(r.command(ctx, "init", "--bare", "--quiet"), "initializing bare repository"); err != nil { + return err + } + if err := r.configure(ctx, true); err != nil { + return err + } + if err := os.Rename(tmp, dir); err != nil { + return err + } + return nil +} + +// validate reports whether the manager has a usable root and repository configuration. +func (m *Manager) validate() error { + if m.root == "" { + return errors.New("gitrepo: empty cache root") + } + for name, cfg := range m.repos { + if !validName(name) { + return fmt.Errorf("gitrepo: invalid repository name %q; want owner/repo", name) + } + if cfg.RemoteURL == "" { + return fmt.Errorf("gitrepo: repository %q has an empty remote URL", name) + } + } + return nil +} + +// validName reports whether name is a two-segment repository export name, +// such as "owner/repo". +func validName(name string) bool { + a, b, ok := strings.Cut(name, "/") + if !ok || a == "" || b == "" || strings.Contains(b, "/") { + return false + } + validSegment := func(s string) bool { + if s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") { + return false + } + for _, c := range s { + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' { + continue + } + return false + } + return true + } + return validSegment(a) && validSegment(b) +} + +func run(cmd *exec.Cmd, what string) error { + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w: %s", what, err, bytes.TrimSpace(out)) + } + return nil +} diff --git a/gitrepo/gitrepo_test.go b/gitrepo/gitrepo_test.go new file mode 100644 index 0000000..a4aaf70 --- /dev/null +++ b/gitrepo/gitrepo_test.go @@ -0,0 +1,378 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bytes" + "context" + "io/fs" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +var testEnv = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_TERMINAL_PROMPT=0", +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = testEnv + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v: %s", args, dir, err, out) + } + return strings.TrimSpace(string(out)) +} + +func upstream(t *testing.T) (dir, first, head string) { + t.Helper() + dir = t.TempDir() + git(t, dir, "init", "-q", "-b", "main") + git(t, dir, "config", "uploadpack.allowFilter", "true") + write := func(name, data string, mode fs.FileMode) { + t.Helper() + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(data), mode); err != nil { + t.Fatal(err) + } + } + write("README.md", "first\n", 0644) + git(t, dir, "add", "-A") + git(t, dir, "commit", "-q", "-m", "first") + first = git(t, dir, "rev-parse", "HEAD") + write("README.md", "second\n", 0644) + write("go.mod", "module example.com/snapshot\n\ngo 1.23.0\n", 0644) + write("cmd/app/main.go", "package main\n\nfunc main() {}\n", 0644) + write("empty", "", 0644) + write("name with spaces", "spaces\n", 0644) + write("run.sh", "#!/bin/sh\n", 0755) + write(".gitignore", "*.tmp\n", 0644) + if err := os.Symlink("README.md", filepath.Join(dir, "readme-link")); err != nil { + t.Fatal(err) + } + git(t, dir, "add", "-A") + git(t, dir, "commit", "-q", "-m", "second") + head = git(t, dir, "rev-parse", "HEAD") + return +} + +func newCheckout(t *testing.T) (*Manager, *checkout, string, string) { + t.Helper() + up, first, head := upstream(t) + remote := &url.URL{ + Scheme: "file", + Path: "/" + strings.TrimPrefix(filepath.ToSlash(up), "/"), + } + m := NewManager(t.TempDir(), map[string]Config{ + "example/repo": { + RemoteURL: remote.String(), + }, + }) + if err := m.validate(); err != nil { + t.Fatal(err) + } + co, err := m.checkout(context.Background(), "example/repo", head) + if err != nil { + t.Fatal(err) + } + return m, co, first, head +} + +func TestManagerValidation(t *testing.T) { + for _, name := range []string{"", "one", "a/b/c", "../b", "a/.."} { + m := NewManager(t.TempDir(), map[string]Config{ + name: { + RemoteURL: "x", + }, + }) + if err := m.validate(); err == nil { + t.Errorf("Validate accepted %q", name) + } + } +} + +func TestCheckout(t *testing.T) { + m, co, _, head := newCheckout(t) + if got := co.commit; got != head { + t.Fatalf("commit = %q; want %q", got, head) + } + if got := git(t, co.repo.dir, "rev-parse", "--is-bare-repository"); got != "true" { + t.Errorf("bare = %q", got) + } + if got := git(t, co.repo.dir, "config", "--get", "remote.origin.partialclonefilter"); got != "blob:none" { + t.Errorf("filter = %q", got) + } + if got := git(t, co.repo.dir, "rev-parse", "refs/gomodfs/checkouts/"+head); got != head { + t.Errorf("pin = %q", got) + } + blob := git(t, co.repo.dir, "rev-parse", head+":README.md") + cmd := exec.Command("git", "cat-file", "-e", blob) + cmd.Dir = co.repo.dir + cmd.Env = append(testEnv, "GIT_NO_LAZY_FETCH=1") + if err := cmd.Run(); err == nil { + t.Errorf("blob %s was fetched eagerly", blob) + } + + for path, want := range map[string]string{ + "README.md": "second\n", + "cmd/app/main.go": "package main\n\nfunc main() {}\n", + ".git/HEAD": head + "\n", + ".git/shallow": head + "\n", + ".git/config": checkoutConfig, + } { + got, err := co.readFile(context.Background(), path) + if err != nil || string(got) != want { + t.Errorf("ReadFile(%q) = %q, %v; want %q", path, got, err, want) + } + } + fi, err := co.stat(context.Background(), ".git/shallow") + if err != nil || fi.Size() != 41 || fi.Mode() != 0444 { + t.Errorf("shallow stat = %v, %v", fi, err) + } + fi, err = co.stat(context.Background(), "run.sh") + if err != nil || fi.Mode()&0111 == 0 { + t.Errorf("run.sh stat = %v, %v", fi, err) + } + fi, err = co.stat(context.Background(), "readme-link") + if err != nil || fi.Mode()&fs.ModeSymlink == 0 { + t.Errorf("link stat = %v, %v", fi, err) + } + if got, err := co.readlink(context.Background(), "readme-link"); err != nil || got != "README.md" { + t.Errorf("Readlink = %q, %v", got, err) + } + loose, err := co.readFile(context.Background(), ".git/objects/"+blob[:2]+"/"+blob[2:]) + if err != nil || len(loose) == 0 { + t.Fatalf("current loose object: %v", err) + } + + m2 := NewManager(m.root, m.repos) + co2, err := m2.checkout(context.Background(), "example/repo", head) + if err != nil { + t.Fatal(err) + } + if got, _ := co2.readFile(context.Background(), "README.md"); string(got) != "second\n" { + t.Errorf("reopened README = %q", got) + } +} + +func materializeCheckout(t *testing.T, co *checkout) string { + t.Helper() + dir := t.TempDir() + var materialize func(string) + materialize = func(p string) { + ents, err := co.readDir(context.Background(), p) + if err != nil { + t.Fatalf("ReadDir(%q): %v", p, err) + } + for _, ent := range ents { + rel := ent.name + if p != "" { + rel = p + "/" + ent.name + } + abs := filepath.Join(dir, filepath.FromSlash(rel)) + switch { + case ent.mode.IsDir(): + if err := os.MkdirAll(abs, 0755); err != nil { + t.Fatal(err) + } + materialize(rel) + case ent.mode&fs.ModeSymlink != 0: + target, err := co.readlink(context.Background(), rel) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, abs); err != nil { + t.Fatal(err) + } + default: + b, err := co.readFile(context.Background(), rel) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, b, ent.mode.Perm()); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(abs, staticTime, staticTime); err != nil { + t.Fatal(err) + } + } + } + } + materialize("") + // Loose object directories aren't enumerable. Copy only the selected + // commit and its tree closure, never its parents or their objects. + tree := git(t, co.repo.dir, "rev-parse", co.commit+"^{tree}") + objects := []string{co.commit, tree} + out := git(t, co.repo.dir, "ls-tree", "-r", "-t", "-z", co.commit) + for _, line := range strings.Split(out, "\x00") { + if line == "" { + continue + } + fields := strings.Fields(strings.SplitN(line, "\t", 2)[0]) + if fields[1] != "commit" { + objects = append(objects, fields[2]) + } + } + for _, sha := range objects { + b, err := co.readFile(context.Background(), ".git/objects/"+sha[:2]+"/"+sha[2:]) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, ".git", "objects", sha[:2], sha[2:]) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, b, 0444); err != nil { + t.Fatal(err) + } + } + gitDir := filepath.Join(dir, ".git") + if err := os.Chmod(gitDir, 0555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(gitDir, 0755) }) + return dir +} + +func TestCheckoutWorksWithGit(t *testing.T) { + _, co, first, head := newCheckout(t) + dir := materializeCheckout(t, co) + if _, err := os.Stat(filepath.Join(dir, ".git", "objects", first[:2], first[2:])); !os.IsNotExist(err) { + t.Fatalf("parent commit present: %v", err) + } + oldBlob := git(t, co.repo.dir, "rev-parse", first+":README.md") + if _, err := os.Stat(filepath.Join(dir, ".git", "objects", oldBlob[:2], oldBlob[2:])); !os.IsNotExist(err) { + t.Fatalf("historical blob present: %v", err) + } + index, err := co.indexFile(t.Context()) + if err != nil { + t.Fatal(err) + } + stamp := git(t, co.repo.dir, "log", "-1", "--format=%H:%ct", head) + tests := []struct { + args []string + want string + }{ + {[]string{"diff-index", "--quiet", "HEAD"}, ""}, + {[]string{"diff", "--no-ext-diff", "--name-only", "--exit-code"}, ""}, + {[]string{"diff", "HEAD"}, ""}, + {[]string{"rev-parse", "HEAD"}, head}, + {[]string{"rev-parse", "--show-toplevel"}, filepath.ToSlash(dir)}, + {[]string{"rev-parse", "--is-shallow-repository"}, "true"}, + {[]string{"-c", "log.showsignature=false", "log", "-1", "--format=%H:%ct"}, stamp}, + {[]string{"rev-list", "--count", "HEAD"}, "1"}, + {[]string{"log", "--format=%H"}, head}, + {[]string{"show", "HEAD:README.md"}, "second"}, + {[]string{"grep", "-l", "-F", "func main"}, "cmd/app/main.go"}, + {[]string{"grep", "--cached", "-l", "-F", "func main"}, "cmd/app/main.go"}, + {[]string{"ls-files"}, git(t, co.repo.dir, "ls-tree", "-r", "--name-only", head)}, + {[]string{"ls-files", "--others", "--exclude-standard"}, ""}, + {[]string{"ls-tree", "-r", "--long", "-z", "HEAD"}, git(t, co.repo.dir, "ls-tree", "-r", "--long", "-z", head)}, + {[]string{"for-each-ref", "--format=%(refname)", "--merged=HEAD"}, ""}, + {[]string{"status", "--porcelain"}, ""}, + } + for _, tt := range tests { + t.Run(strings.Join(tt.args, " "), func(t *testing.T) { + if got := git(t, dir, tt.args...); got != tt.want { + t.Errorf("got %q; want %q", got, tt.want) + } + }) + } + + if after, err := os.ReadFile(filepath.Join(dir, ".git", "index")); err != nil || !bytes.Equal(index, after) { + t.Errorf("Git changed the synthesized index: %v", err) + } +} + +func TestIndexKeepsBlobsLazy(t *testing.T) { + _, co, _, _ := newCheckout(t) + missing := func() string { + return git(t, co.repo.dir, "rev-list", "--objects", "--missing=print", co.commit) + } + before := missing() + if !strings.Contains(before, "\n?") { + t.Fatal("fixture has no promised blobs") + } + ctx := context.Background() + if _, err := co.readDir(ctx, ""); err != nil { + t.Fatal(err) + } + idx, err := co.indexFile(ctx) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{".git/index", "/.git/index"} { + fi, err := co.stat(ctx, p) + if err != nil { + t.Fatal(err) + } + if fi.Size() != int64(len(idx)) || !fi.ModTime().Equal(staticTime) { + t.Errorf("Stat(%q) = %v", p, fi) + } + data, err := co.readFile(ctx, p) + if err != nil || !bytes.Equal(data, idx) { + t.Errorf("ReadFile(%q) differs from Index: %v", p, err) + } + } + if after := missing(); after != before { + t.Error("directory/index access fetched promised blobs") + } + idx[0] = 0 + if again, err := co.indexFile(ctx); err != nil || string(again[:4]) != "DIRC" { + t.Errorf("Index cache aliased returned bytes: %v", err) + } +} + +func TestIndexErrorRetry(t *testing.T) { + _, co, _, _ := newCheckout(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := co.readFile(ctx, ".git/index"); err == nil { + t.Error("ReadFile ignored index generation error") + } + if _, err := co.stat(ctx, ".git/index"); err == nil { + t.Error("Stat ignored index generation error") + } + if co.index != nil { + t.Error("failed Index populated cache") + } + if _, err := co.indexFile(t.Context()); err != nil { + t.Fatalf("retry: %v", err) + } +} + +func TestConcurrentCheckouts(t *testing.T) { + m, _, _, head := newCheckout(t) + m = NewManager(m.root, m.repos) + m.RegisterMetrics(prometheus.NewRegistry()) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + co, err := m.checkout(context.Background(), "example/repo", head) + if err != nil { + t.Error(err) + return + } + if _, err := co.readFile(context.Background(), "README.md"); err != nil { + t.Error(err) + } + }) + } + wg.Wait() +} diff --git a/gitrepo/nfs.go b/gitrepo/nfs.go new file mode 100644 index 0000000..f8283bf --- /dev/null +++ b/gitrepo/nfs.go @@ -0,0 +1,334 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "context" + "crypto/sha256" + "errors" + "hash/fnv" + "io" + "math" + "net" + "os" + "path" + "slices" + "strings" + "sync" + "time" + + "github.com/go-git/go-billy/v5" + "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" + nfsfile "github.com/willscott/go-nfs/file" +) + +// NFSHandler retains checkouts and their handles until the last +// [NFSHandler.Acquire] caller releases them. Handles are stable SHA-256 hashes +// of the repository name, commit, and path, but their reverse mappings are not +// persisted or reconstructed. After release or restart, a handle is stale until +// the checkout is acquired and ToHandle registers that path again. +type NFSHandler struct { + repos *Manager // repos opens the immutable checkout behind each acquired export. + owner nfsfile.FileInfo // owner is the common NFS ownership and link-count metadata. + + // mu protects exports, handles, and the mutable fields of each repoExport. + mu sync.Mutex + exports map[string]*repoExport // maps / mount paths to acquired checkouts. + handles map[[sha256.Size]byte]repoPathHandle // maps NFS handles to their corresponding repo. +} + +// repoExport holds the shared state for one acquired repository checkout. +type repoExport struct { + co *checkout + + // refs counts active Acquire leases for the checkout. + refs int + + // paths maps a path within a repo to its NFS handle. It's tracked so + // we can garbage collect unused handles from NFSHandler once the export is + // no longer referenced. + paths map[string][sha256.Size]byte +} + +// repoPathHandle tracks a path within a specific exported repo. +type repoPathHandle struct { + export *repoExport // export is the acquired checkout that owns the handle. + path string // path is relative to the root of export. +} + +var _ nfs.ReadHandler = (*NFSHandler)(nil) + +// NewNFSHandler returns a read-only NFS handler for checkouts managed by repos. +func NewNFSHandler(repos *Manager, uid, gid uint32) (*NFSHandler, error) { + if err := repos.validate(); err != nil { + return nil, err + } + return &NFSHandler{ + repos: repos, + owner: nfsfile.FileInfo{ + UID: uid, + GID: gid, + Nlink: 1, + }, + exports: map[string]*repoExport{}, + handles: map[[sha256.Size]byte]repoPathHandle{}, + }, nil +} + +// Acquire makes /repos// mountable until release is called. +// Call once per guest before mounting, and release after that guest stops. +// Release is idempotent; the last release discards the checkout's handle +// mappings and frees its in-memory state. The on-disk Git cache is retained. +func (h *NFSHandler) Acquire(ctx context.Context, repo, commit string) (release func(), err error) { + key := repo + "/" + commit + h.mu.Lock() + e := h.exports[key] + if e != nil { + e.refs++ + } + h.mu.Unlock() + if e == nil { + co, err := h.repos.checkout(ctx, repo, commit) + if err != nil { + return nil, err + } + h.mu.Lock() + e = h.exports[key] + if e == nil { + e = &repoExport{ + co: co, + paths: map[string][sha256.Size]byte{}, + } + h.exports[key] = e + } + e.refs++ + h.mu.Unlock() + } + return sync.OnceFunc(func() { + h.mu.Lock() + defer h.mu.Unlock() + e.refs-- + if e.refs == 0 { + delete(h.exports, key) + for _, id := range e.paths { + delete(h.handles, id) + } + } + }), nil +} + +// Mount resolves an acquired /repos// export. +func (h *NFSHandler) Mount(_ context.Context, _ net.Conn, req nfs.MountRequest) (nfs.MountStatus, billy.Filesystem, []nfs.AuthFlavor) { + key, ok := strings.CutPrefix(strings.Trim(string(req.Dirpath), "/"), "repos/") + if !ok { + return nfs.MountStatusErrNoEnt, nil, nil + } + h.mu.Lock() + e := h.exports[key] + h.mu.Unlock() + if e == nil { + return nfs.MountStatusErrNoEnt, nil, nil + } + return nfs.MountStatusOk, h.filesystem(e), []nfs.AuthFlavor{nfs.AuthFlavorNull} +} + +func (h *NFSHandler) filesystem(e *repoExport) checkoutBilly { + return checkoutBilly{ + export: e, + owner: h.owner, + } +} + +// Change returns nil because repository checkouts are read-only. +func (h *NFSHandler) Change(billy.Filesystem) billy.Change { return nil } + +// FSStat leaves filesystem capacity fields at their default values. +func (h *NFSHandler) FSStat(context.Context, billy.Filesystem, *nfs.FSStat) error { + return nil +} + +// InvalidateHandle is a no-op because handle mappings live until their export is released. +func (h *NFSHandler) InvalidateHandle(billy.Filesystem, []byte) error { return nil } + +// HandleLimit reports that the handler imposes no practical handle limit. +func (h *NFSHandler) HandleLimit() int { return math.MaxInt } + +// ToHandle returns a stable handle for segs and registers its reverse mapping +// for the lifetime of the acquired export. +func (h *NFSHandler) ToHandle(f billy.Filesystem, segs []string) []byte { + // go-nfs calls ToHandle even after a rejected mount with a nil filesystem. + b, ok := f.(checkoutBilly) + if !ok { + return make([]byte, sha256.Size) + } + p := strings.Join(segs, "/") + h.mu.Lock() + defer h.mu.Unlock() + if b.export.refs == 0 { + return make([]byte, sha256.Size) + } + id, ok := b.export.paths[p] + if !ok { + // NUL separators make the encoding unambiguous: repository names and + // commit hashes cannot contain NULs. + co := b.export.co + id = sha256.Sum256([]byte(co.repo.name + "\x00" + co.commit + "\x00" + p)) + b.export.paths[p] = id + h.handles[id] = repoPathHandle{ + export: b.export, + path: p, + } + } + return slices.Clone(id[:]) +} + +func staleErr(err error) error { + return &nfs.NFSStatusError{ + NFSStatus: nfs.NFSStatusStale, + WrappedErr: err, + } +} + +// FromHandle resolves an opaque handle to its live checkout and path. +func (h *NFSHandler) FromHandle(handle []byte) (billy.Filesystem, []string, error) { + if len(handle) != sha256.Size { + return nil, nil, staleErr(errors.New("invalid repository handle length")) + } + id := [sha256.Size]byte(handle) + h.mu.Lock() + entry, ok := h.handles[id] + h.mu.Unlock() + if !ok { + return nil, nil, staleErr(os.ErrNotExist) + } + var segs []string + if entry.path != "" { + segs = strings.Split(entry.path, "/") + } + return h.filesystem(entry.export), segs, nil +} + +// OnNFSRead reads file contents directly from the checkout backing handle. +func (h *NFSHandler) OnNFSRead(ctx context.Context, handle []byte, offset uint64, count uint32) (*nfs.NFSReadResult, error) { + f, segs, err := h.FromHandle(handle) + if err != nil { + return nil, err + } + b := f.(checkoutBilly) + p := strings.Join(segs, "/") + data, err := b.export.co.readFile(ctx, p) + if err != nil { + if os.IsNotExist(err) { + return nil, &nfs.NFSStatusError{ + NFSStatus: nfs.NFSStatusNoEnt, + WrappedErr: err, + } + } + return nil, err + } + fi, err := b.Lstat(p) + if err != nil { + return nil, err + } + size := uint64(len(data)) + start := min(size, offset) + end := start + min(size-start, uint64(count), uint64(nfs.MaxRead)) + return &nfs.NFSReadResult{ + Data: data[start:end], + EOF: end == size, + Attr: nfs.ToFileAttribute(fi, p), + }, nil +} + +// checkoutFileInfo combines checkout metadata with attributes used by go-nfs. +type checkoutFileInfo struct { + fileInfo os.FileInfo // fileInfo is the metadata reported by the underlying checkout. + nfsAttrs nfsfile.FileInfo // nfsAttrs supplies ownership and file identity through Sys. +} + +var _ os.FileInfo = (*checkoutFileInfo)(nil) + +func (fi *checkoutFileInfo) Name() string { return fi.fileInfo.Name() } +func (fi *checkoutFileInfo) Size() int64 { return fi.fileInfo.Size() } +func (fi *checkoutFileInfo) Mode() os.FileMode { return fi.fileInfo.Mode() } +func (fi *checkoutFileInfo) ModTime() time.Time { return fi.fileInfo.ModTime() } +func (fi *checkoutFileInfo) IsDir() bool { return fi.fileInfo.IsDir() } +func (fi *checkoutFileInfo) Sys() any { return fi.nfsAttrs } + +// checkoutBilly adapts a live immutable checkout to the metadata-oriented +// subset of billy.Filesystem used by go-nfs. File data bypasses this adapter +// and is served by NFSHandler.OnNFSRead. +type checkoutBilly struct { + export *repoExport // export identifies the live checkout and ties the adapter to its lease. + owner nfsfile.FileInfo // owner is the NFS attribute template applied to files in the checkout. +} + +var _ billy.Filesystem = checkoutBilly{} + +func (checkoutBilly) Capabilities() billy.Capability { + return billy.ReadCapability | billy.SeekCapability +} +func (checkoutBilly) Join(e ...string) string { return path.Join(e...) } +func (checkoutBilly) Root() string { return "/" } +func (checkoutBilly) Chroot(string) (billy.Filesystem, error) { return nil, errors.ErrUnsupported } +func (checkoutBilly) Create(string) (billy.File, error) { return nil, errRepositoryReadonly } + +// File reads are handled by OnNFSRead instead, which is defined in the +// [nfs.ReadHandler] interface. +func (checkoutBilly) Open(string) (billy.File, error) { return nil, errors.ErrUnsupported } +func (checkoutBilly) OpenFile(string, int, os.FileMode) (billy.File, error) { + return nil, errors.ErrUnsupported +} + +var errRepositoryReadonly = errors.New("repository checkout is read-only") + +func (checkoutBilly) Rename(string, string) error { return errRepositoryReadonly } +func (checkoutBilly) Remove(string) error { return errRepositoryReadonly } +func (checkoutBilly) TempFile(string, string) (billy.File, error) { return nil, errRepositoryReadonly } +func (checkoutBilly) MkdirAll(string, os.FileMode) error { return errRepositoryReadonly } +func (checkoutBilly) Symlink(string, string) error { return errRepositoryReadonly } + +func (b checkoutBilly) own(fi os.FileInfo, p string) os.FileInfo { + o := b.owner + h := fnv.New64() + io.WriteString(h, b.export.co.repo.name) + io.WriteString(h, b.export.co.commit) + io.WriteString(h, p) + o.Fileid = h.Sum64() + return &checkoutFileInfo{ + fileInfo: fi, + nfsAttrs: o, + } +} +func (b checkoutBilly) Stat(p string) (os.FileInfo, error) { return b.Lstat(p) } +func (b checkoutBilly) Lstat(p string) (os.FileInfo, error) { + fi, err := b.export.co.stat(context.Background(), p) + if err != nil { + return nil, err + } + return b.own(fi, p), nil +} +func (b checkoutBilly) Readlink(p string) (string, error) { + return b.export.co.readlink(context.Background(), p) +} +func (b checkoutBilly) ReadDir(p string) ([]os.FileInfo, error) { + ents, err := b.export.co.readDir(context.Background(), p) + if err != nil { + return nil, err + } + ret := make([]os.FileInfo, 0, len(ents)) + for _, e := range ents { + child := e.name + if p != "" { + child = p + "/" + child + } + fi, err := b.export.co.stat(context.Background(), child) + if err != nil { + return nil, err + } + ret = append(ret, b.own(fi, child)) + } + slices.SortFunc(ret, func(a, b os.FileInfo) int { return strings.Compare(a.Name(), b.Name()) }) + return ret, nil +} diff --git a/gitrepo/nfs_test.go b/gitrepo/nfs_test.go new file mode 100644 index 0000000..ffc6111 --- /dev/null +++ b/gitrepo/nfs_test.go @@ -0,0 +1,250 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "bytes" + "crypto/sha256" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" +) + +func testRepositoryHandler(t *testing.T) (*NFSHandler, string, string) { + t.Helper() + upstream := t.TempDir() + git(t, upstream, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(upstream, "hello.txt"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + git(t, upstream, "add", "-A") + git(t, upstream, "commit", "-q", "-m", "initial") + sha := git(t, upstream, "rev-parse", "HEAD") + manager := NewManager(t.TempDir(), map[string]Config{ + "example/repo": { + RemoteURL: upstream, + }, + }) + h, err := NewNFSHandler(manager, 1234, 5678) + if err != nil { + t.Fatal(err) + } + return h, upstream, sha +} + +func acquireRepository(t *testing.T, h *NFSHandler, sha string) func() { + t.Helper() + release, err := h.Acquire(t.Context(), "example/repo", sha) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) + return release +} + +func TestNFSRepositoryLifetime(t *testing.T) { + h, _, sha := testRepositoryHandler(t) + req := nfs.MountRequest{ + Dirpath: []byte("/repos/example/repo/" + sha), + } + if status, _, _ := h.Mount(t.Context(), nil, req); status == nfs.MountStatusOk { + t.Fatal("unacquired checkout is mountable") + } + first := acquireRepository(t, h, sha) + second := acquireRepository(t, h, sha) + _, f, _ := h.Mount(t.Context(), nil, req) + file := h.ToHandle(f, []string{"hello.txt"}) + h.ToHandle(f, []string{"another-path"}) + first() + first() + _, f2, _ := h.Mount(t.Context(), nil, req) + if got := h.ToHandle(f2, []string{"hello.txt"}); !bytes.Equal(got, file) { + t.Fatal("same checkout/path has different handles") + } + if _, segs, err := h.FromHandle(file); err != nil || strings.Join(segs, "/") != "hello.txt" { + t.Fatalf("live handle = %q, %v", segs, err) + } + second() + for _, handle := range [][]byte{file, h.ToHandle(f, nil), h.ToHandle(nil, nil)} { + _, _, err := h.FromHandle(handle) + var status *nfs.NFSStatusError + if !errors.As(err, &status) || status.NFSStatus != nfs.NFSStatusStale { + t.Fatalf("released handle error = %v; want NFS stale handle", err) + } + } + if len(h.handles) != 0 || len(h.exports) != 0 { + t.Fatal("last release retained checkout state") + } + if status, _, _ := h.Mount(t.Context(), nil, req); status == nfs.MountStatusOk { + t.Fatal("released checkout is still mountable") + } + acquireRepository(t, h, sha) + _, f3, _ := h.Mount(t.Context(), nil, req) + if !bytes.Equal(file, h.ToHandle(f3, []string{"hello.txt"})) { + t.Fatal("reacquire changed the handle") + } + if _, segs, err := h.FromHandle(file); err != nil || strings.Join(segs, "/") != "hello.txt" { + t.Fatalf("reacquired handle = %q, %v", segs, err) + } + + // A fresh handler produces the same handles, but cannot resolve them + // until ToHandle has registered the path in its own reverse index. + fresh, err := NewNFSHandler(h.repos, 999, 999) + if err != nil { + t.Fatal(err) + } + acquireRepository(t, fresh, sha) + _, f4, _ := fresh.Mount(t.Context(), nil, req) + if _, _, err := fresh.FromHandle(file); err == nil { + t.Fatal("fresh handler resolved an unregistered handle") + } + if !bytes.Equal(file, fresh.ToHandle(f4, []string{"hello.txt"})) { + t.Fatal("fresh handler changed the handle") + } + if _, segs, err := fresh.FromHandle(file); err != nil || strings.Join(segs, "/") != "hello.txt" { + t.Fatalf("fresh handler registered handle = %q, %v", segs, err) + } +} + +func TestNFSRepositoryHandleIdentity(t *testing.T) { + h := &NFSHandler{ + handles: map[[sha256.Size]byte]repoPathHandle{}, + } + seen := map[string]bool{} + for _, tt := range []struct { + repo, commit, path string + }{ + {"example/repo", strings.Repeat("a", 40), "hello.txt"}, + {"example/other", strings.Repeat("a", 40), "hello.txt"}, + {"example/repo", strings.Repeat("b", 40), "hello.txt"}, + {"example/repo", strings.Repeat("a", 40), "dir/hello.txt"}, + {"example/repo", strings.Repeat("a", 40), ""}, + {"example/repo", strings.Repeat("a", 40), ".git/HEAD"}, + } { + e := &repoExport{ + co: &checkout{ + repo: &repository{ + name: tt.repo, + }, + commit: tt.commit, + }, + refs: 1, + paths: map[string][sha256.Size]byte{}, + } + var segs []string + if tt.path != "" { + segs = strings.Split(tt.path, "/") + } + got := h.ToHandle(h.filesystem(e), segs) + want := sha256.Sum256([]byte(tt.repo + "\x00" + tt.commit + "\x00" + tt.path)) + if !bytes.Equal(got, want[:]) { + t.Fatalf("handle(%q, %q, %q) = %x; want %x", tt.repo, tt.commit, tt.path, got, want) + } + if seen[string(got)] { + t.Fatal("different identities produced the same handle") + } + seen[string(got)] = true + if _, decoded, err := h.FromHandle(got); err != nil || strings.Join(decoded, "/") != tt.path { + t.Fatalf("FromHandle = %q, %v; want %q", decoded, err, tt.path) + } + } + for _, malformed := range [][]byte{nil, make([]byte, 16), make([]byte, 31), make([]byte, 33), make([]byte, 32)} { + _, _, err := h.FromHandle(malformed) + var status *nfs.NFSStatusError + if !errors.As(err, &status) || status.NFSStatus != nfs.NFSStatusStale { + t.Fatalf("FromHandle(%x) error = %v; want NFS stale handle", malformed, err) + } + } +} + +func TestNFSRepositoryConcurrentGuests(t *testing.T) { + h, _, sha := testRepositoryHandler(t) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + for range 4 { + release, err := h.Acquire(t.Context(), "example/repo", sha) + if err != nil { + t.Error(err) + return + } + _, f, _ := h.Mount(t.Context(), nil, nfs.MountRequest{ + Dirpath: []byte("/repos/example/repo/" + sha), + }) + handle := h.ToHandle(f, []string{"hello.txt"}) + if _, _, err := h.FromHandle(handle); err != nil { + t.Error(err) + } + release() + } + }) + } + wg.Wait() + if len(h.handles) != 0 || len(h.exports) != 0 { + t.Fatal("guest teardown leaked state") + } +} + +func TestNFSRepositoryAcquireValidation(t *testing.T) { + h, _, sha := testRepositoryHandler(t) + for _, tt := range []struct { + repo string + commit string + }{ + {"example/repo", "not-a-sha"}, + {"unknown/repo", sha}, + {"../repo", sha}, + } { + if release, err := h.Acquire(t.Context(), tt.repo, tt.commit); err == nil { + release() + t.Errorf("Acquire(%q, %q) succeeded", tt.repo, tt.commit) + } + } + if len(h.exports) != 0 || len(h.handles) != 0 { + t.Fatal("failed acquire left state") + } +} + +func TestNFSRepositoryRead(t *testing.T) { + h, _, sha := testRepositoryHandler(t) + acquireRepository(t, h, sha) + _, f, _ := h.Mount(t.Context(), nil, nfs.MountRequest{ + Dirpath: []byte("/repos/example/repo/" + sha), + }) + fi, err := f.Lstat("hello.txt") + if err != nil { + t.Fatal(err) + } + wantAttr := nfs.ToFileAttribute(fi, "hello.txt") + handle := h.ToHandle(f, []string{"hello.txt"}) + for _, tt := range []struct { + offset uint64 + count uint32 + want string + eof bool + }{ + {0, 0, "", false}, + {0, 6, "hello\n", true}, + {1, 3, "ell", false}, + {4, 100, "o\n", true}, + {6, 100, "", true}, + {^uint64(0), 100, "", true}, + } { + res, err := h.OnNFSRead(t.Context(), handle, tt.offset, tt.count) + if err != nil { + t.Fatal(err) + } + if string(res.Data) != tt.want || res.EOF != tt.eof { + t.Errorf("read(%d, %d) = %q, EOF %v; want %q, %v", tt.offset, tt.count, res.Data, res.EOF, tt.want, tt.eof) + } + if res.Attr.Fileid != wantAttr.Fileid || res.Attr.UID != 1234 || res.Attr.GID != 5678 { + t.Errorf("read attributes disagree with lookup/owner: %+v", res.Attr) + } + } +} diff --git a/gitrepo/repository.go b/gitrepo/repository.go new file mode 100644 index 0000000..9f4cc51 --- /dev/null +++ b/gitrepo/repository.go @@ -0,0 +1,95 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package gitrepo + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) + +// repository is one repository-scoped bare partial mirror. +type repository struct { + name string + dir string + cfg Config + mgr *Manager // Back-reference for adding to metrics. + + fetchMu sync.Mutex +} + +func (r *repository) command(ctx context.Context, args ...string) *exec.Cmd { + all := []string{"-c", "gc.auto=0", "-c", "maintenance.auto=false"} + all = append(all, args...) + cmd := exec.CommandContext(ctx, "git", all...) + cmd.Dir = r.dir + return cmd +} + +func (r *repository) configure(ctx context.Context, add bool) error { + verb := "set-url" + if add { + verb = "add" + } + if err := run(r.command(ctx, "remote", verb, "origin", r.cfg.RemoteURL), "configuring repository remote"); err != nil { + if add { + return err + } + // Repair a bare repository left without a remote by an interrupted or + // older initialization. + if err := run(r.command(ctx, "remote", "add", "origin", r.cfg.RemoteURL), "adding repository remote"); err != nil { + return err + } + } + commands := [][]string{ + {"config", "remote.origin.promisor", "true"}, + {"config", "remote.origin.partialclonefilter", "blob:none"}, + {"config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"}, + } + for _, args := range commands { + if err := run(r.command(ctx, args...), "configuring repository"); err != nil { + return err + } + } + return nil +} + +func (r *repository) ensureCommit(ctx context.Context, commit string) (retErr error) { + r.fetchMu.Lock() + defer r.fetchMu.Unlock() + if r.hasCommit(ctx, commit) { + r.mgr.metrics.fetches.WithLabelValues(r.name, "hit").Inc() + return r.pin(ctx, commit) + } + start := time.Now() + defer func() { + result := "fetched" + if retErr != nil { + result = "error" + } + r.mgr.metrics.fetches.WithLabelValues(r.name, result).Inc() + r.mgr.metrics.fetchDuration.WithLabelValues(r.name).Observe(time.Since(start).Seconds()) + }() + ref := "refs/gomodfs/checkouts/" + commit + args := []string{"fetch", "--quiet", "--filter=blob:none", "--no-tags", "origin", commit + ":" + ref} + if err := run(r.command(ctx, args...), "fetching commit"); err != nil { + return err + } + if !r.hasCommit(ctx, commit) { + return fmt.Errorf("fetched object %s is not the requested commit", commit) + } + return nil +} + +func (r *repository) hasCommit(ctx context.Context, commit string) bool { + out, err := r.command(ctx, "rev-parse", "--verify", "--end-of-options", commit+"^{commit}").Output() + return err == nil && strings.TrimSpace(string(out)) == commit +} + +func (r *repository) pin(ctx context.Context, commit string) error { + return run(r.command(ctx, "update-ref", "refs/gomodfs/checkouts/"+commit, commit), "pinning commit") +} diff --git a/nfsexports/nfs.go b/nfsexports/nfs.go new file mode 100644 index 0000000..d37eb82 --- /dev/null +++ b/nfsexports/nfs.go @@ -0,0 +1,176 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package nfsexports shares an NFS endpoint between the module cache and Git +// checkouts so rpcbind-dependent clients can mount both from one server. +package nfsexports + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "math" + "net" + "strings" + + "github.com/go-git/go-billy/v5" + "github.com/tailscale/gomodfs" + "github.com/tailscale/gomodfs/gitrepo" + "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" +) + +// New creates a [nfs.ReadHandler] that routes between go mod and git repo +// exports based on a handle prefix. Both arguments must be non-nil. +func New(mfs *gomodfs.FS, gfs *gitrepo.NFSHandler) nfs.ReadHandler { + return &handler{ + mfs: mfs.NFSHandler().(nfs.ReadHandler), + gfs: gfs, + } +} + +type handler struct { + mfs nfs.ReadHandler + gfs *gitrepo.NFSHandler +} + +var _ nfs.ReadHandler = (*handler)(nil) + +type handleKind byte + +const ( + moduleHandleKind handleKind = 0 + repoHandleKind handleKind = 1 + handleMagic = "GOMODFS" + handleVersion byte = 1 +) + +var errStaleHandle = &nfs.NFSStatusError{ + NFSStatus: nfs.NFSStatusStale, + WrappedErr: errors.New("invalid export handle"), +} + +// decodeHandle determines whether the requested handle belongs to a go mod or +// git repo export. Git repo handles have a magic header and version/kind bytes +// that precede the actual handle payload which [gitrepo.NFSHandler] understands. +func decodeHandle(handle []byte) (handleKind, []byte, error) { + // The original go module handles are composed of 2 SHA256 hashes occupying + // all 64 bytes allowed by NFSv3. Handles for other exports must be shorter + // to distinguish them from go mod handles. The short not-exist sentinel is + // also part of the original wire format. + if len(handle) == 64 || bytes.Equal(handle, []byte("\x00\xFF\x00Nope")) { + return moduleHandleKind, handle, nil + } + const headerLen = len(handleMagic) + 2 + if len(handle) >= 64 || len(handle) < headerLen+1 || !bytes.HasPrefix(handle, []byte(handleMagic)) { + return 0, nil, errStaleHandle + } + if handle[len(handleMagic)] != handleVersion { + return 0, nil, errStaleHandle + } + kind := handleKind(handle[len(handleMagic)+1]) + payload := handle[headerLen:] + switch kind { + case repoHandleKind: + if len(payload) != sha256.Size { + return 0, nil, errStaleHandle + } + default: + return 0, nil, errStaleHandle + } + return kind, payload, nil +} + +type repoFS struct{ billy.Filesystem } + +func (repoFS) Capabilities() billy.Capability { + return billy.ReadCapability | billy.SeekCapability +} + +// Mount implements [nfs.ReadHandler]. +func (h *handler) Mount(ctx context.Context, conn net.Conn, req nfs.MountRequest) (nfs.MountStatus, billy.Filesystem, []nfs.AuthFlavor) { + p := strings.Trim(string(req.Dirpath), "/") + if p == "repos" || strings.HasPrefix(p, "repos/") { + status, f, flavors := h.gfs.Mount(ctx, conn, req) + if status == nfs.MountStatusOk { + f = repoFS{ + Filesystem: f, + } + } + return status, f, flavors + } + // Existing clients use arbitrary names, including /modfs and /gomodfs. + return h.mfs.Mount(ctx, conn, req) +} + +// ToHandle implements [nfs.ReadHandler]. +func (h *handler) ToHandle(f billy.Filesystem, segs []string) []byte { + if f, ok := f.(repoFS); ok { + header := append([]byte(handleMagic), handleVersion, byte(repoHandleKind)) + return append(header, h.gfs.ToHandle(f.Filesystem, segs)...) + } + return h.mfs.ToHandle(f, segs) +} + +// FromHandle implements [nfs.ReadHandler]. +func (h *handler) FromHandle(handle []byte) (billy.Filesystem, []string, error) { + kind, payload, err := decodeHandle(handle) + if err != nil { + return nil, nil, err + } + if kind == repoHandleKind { + f, segs, err := h.gfs.FromHandle(payload) + if err != nil { + return nil, nil, err + } + return repoFS{ + Filesystem: f, + }, segs, nil + } + return h.mfs.FromHandle(payload) +} + +// OnNFSRead implements [nfs.ReadHandler]. +func (h *handler) OnNFSRead(ctx context.Context, handle []byte, offset uint64, count uint32) (*nfs.NFSReadResult, error) { + kind, payload, err := decodeHandle(handle) + if err != nil { + return nil, err + } + if kind == repoHandleKind { + return h.gfs.OnNFSRead(ctx, payload, offset, count) + } + return h.mfs.OnNFSRead(ctx, payload, offset, count) +} + +func (h *handler) FSStat(ctx context.Context, f billy.Filesystem, stat *nfs.FSStat) error { + if f, ok := f.(repoFS); ok { + return h.gfs.FSStat(ctx, f.Filesystem, stat) + } + return h.mfs.FSStat(ctx, f, stat) +} + +// InvalidateHandle implements [nfs.ReadHandler]. +func (h *handler) InvalidateHandle(f billy.Filesystem, handle []byte) error { + kind, payload, err := decodeHandle(handle) + if err != nil { + return err + } + rf, isRepo := f.(repoFS) + if (kind == repoHandleKind) != isRepo { + return errStaleHandle + } + if isRepo { + return h.gfs.InvalidateHandle(rf.Filesystem, payload) + } + return h.mfs.InvalidateHandle(f, payload) +} + +// Change implements [nfs.ReadHandler]. +func (h *handler) Change(billy.Filesystem) billy.Change { + return nil +} + +// HandleLimit implements [nfs.ReadHandler]. +func (h *handler) HandleLimit() int { + return math.MaxInt +} diff --git a/nfsexports/nfs_test.go b/nfsexports/nfs_test.go new file mode 100644 index 0000000..9dca860 --- /dev/null +++ b/nfsexports/nfs_test.go @@ -0,0 +1,290 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package nfsexports + +import ( + "bytes" + "errors" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/tailscale/gomodfs" + "github.com/tailscale/gomodfs/gitrepo" + "github.com/tailscale/gomodfs/temp-dev-fork/willscott/go-nfs" + clientnfs "github.com/willscott/go-nfs-client/nfs" + clientrpc "github.com/willscott/go-nfs-client/nfs/rpc" +) + +func exportGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func testRepositoryHandler(t *testing.T) (*gitrepo.NFSHandler, string, string) { + t.Helper() + upstream := t.TempDir() + exportGit(t, upstream, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(upstream, "hello.txt"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + exportGit(t, upstream, "add", "-A") + exportGit(t, upstream, "commit", "-q", "-m", "initial") + manager := gitrepo.NewManager(t.TempDir(), map[string]gitrepo.Config{ + "example/repo": { + RemoteURL: upstream, + }, + }) + h, err := gitrepo.NewNFSHandler(manager, 0, 0) + if err != nil { + t.Fatal(err) + } + return h, upstream, exportGit(t, upstream, "rev-parse", "HEAD") +} + +func acquireRepository(t *testing.T, h *gitrepo.NFSHandler, sha string) { + t.Helper() + release, err := h.Acquire(t.Context(), "example/repo", sha) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) +} + +func TestNFSExportRouting(t *testing.T) { + repos, _, sha := testRepositoryHandler(t) + release, err := repos.Acquire(t.Context(), "example/repo", sha) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) + mfs := &gomodfs.FS{} + modules := mfs.NFSHandler() + h := New(mfs, repos) + for _, p := range []string{"/", "/gomodcache", "/gomodfs", "/modfs"} { + status, f, _ := h.Mount(t.Context(), fakeConn{}, nfs.MountRequest{ + Dirpath: []byte(p), + }) + if status != nfs.MountStatusOk { + t.Fatalf("Mount(%q): %v", p, status) + } + for _, segs := range [][]string{nil, {"cache"}, {".nonexistent"}, {".gomodfs-status"}} { + got := h.ToHandle(f, segs) + want := modules.ToHandle(f, segs) + if !bytes.Equal(got, want) { + t.Errorf("module handle changed: %x != %x", got, want) + } + if _, _, err := h.FromHandle(got); err != nil { + t.Fatal(err) + } + if err := h.InvalidateHandle(f, got); err != nil { + t.Fatal(err) + } + } + } + for _, p := range []string{"/repos", "/repos/unknown", "/repos/example/repo/" + strings.Repeat("0", 40)} { + status, _, _ := h.Mount(t.Context(), fakeConn{}, nfs.MountRequest{ + Dirpath: []byte(p), + }) + if status == nfs.MountStatusOk { + t.Errorf("invalid repo mount %q fell through to modules", p) + } + } + status, f, _ := h.Mount(t.Context(), fakeConn{}, nfs.MountRequest{ + Dirpath: []byte("/repos/example/repo/" + sha), + }) + if status != nfs.MountStatusOk { + t.Fatal(status) + } + handle := h.ToHandle(f, []string{"hello.txt"}) + wantHandle := append([]byte("GOMODFS\x01\x01"), repos.ToHandle(f.(repoFS).Filesystem, []string{"hello.txt"})...) + if !bytes.Equal(handle, wantHandle) { + t.Fatalf("repository handle = %x; want %x", handle, wantHandle) + } + res, err := h.OnNFSRead(t.Context(), handle, 0, 6) + if err != nil || res == nil || string(res.Data) != "hello\n" { + t.Fatalf("repository read = %v, %v", res, err) + } + decodedFS, decoded, err := h.FromHandle(handle) + if err != nil || strings.Join(decoded, "/") != "hello.txt" { + t.Fatalf("FromHandle = %q, %v", decoded, err) + } + if got := h.ToHandle(decodedFS, decoded); !bytes.Equal(got, handle) { + t.Fatalf("round-trip handle = %x; want %x", got, handle) + } + if err := h.InvalidateHandle(decodedFS, handle); err != nil { + t.Fatal(err) + } + checkStaleHandle(t, h.InvalidateHandle(nil, handle)) + checkStaleHandle(t, h.InvalidateHandle(decodedFS, modules.ToHandle(nil, nil))) + if billy.CapabilityCheck(f, billy.WriteCapability) { + t.Fatal("wrapped repository advertises writes") + } + if h.Change(f) != nil { + t.Fatal("repository supports changes") + } + if err := f.Remove("hello.txt"); err == nil { + t.Fatal("repository supports remove") + } + release() + _, _, err = h.FromHandle(handle) + checkStaleHandle(t, err) + _, err = h.OnNFSRead(t.Context(), handle, 0, 6) + checkStaleHandle(t, err) +} + +func checkStaleHandle(t *testing.T, err error) { + t.Helper() + var status *nfs.NFSStatusError + if !errors.As(err, &status) || status.NFSStatus != nfs.NFSStatusStale { + t.Fatalf("error = %v; want NFS stale handle", err) + } +} + +func TestDecodeHandle(t *testing.T) { + modules := (&gomodfs.FS{}).NFSHandler() + magicHash := make([]byte, 64) + copy(magicHash, "GOMODFS\xff\xff") + for _, tt := range []struct { + name string + handle []byte + kind handleKind + payload []byte + }{ + {"module-root", modules.ToHandle(nil, nil), moduleHandleKind, make([]byte, 64)}, + {"module-magic-hash", magicHash, moduleHandleKind, magicHash}, + {"module-not-exist", modules.ToHandle(nil, []string{".nonexistent"}), moduleHandleKind, []byte("\x00\xFF\x00Nope")}, + {"repo", append([]byte("GOMODFS\x01\x01"), make([]byte, 32)...), repoHandleKind, make([]byte, 32)}, + } { + t.Run(tt.name, func(t *testing.T) { + kind, payload, err := decodeHandle(tt.handle) + if err != nil || kind != tt.kind || !bytes.Equal(payload, tt.payload) { + t.Fatalf("decodeHandle = %d, %x, %v; want %d, %x", kind, payload, err, tt.kind, tt.payload) + } + }) + } +} + +func TestInvalidExportHandles(t *testing.T) { + for _, tt := range []struct { + name string + handle []byte + }{ + {"empty", nil}, + {"bare-hash", make([]byte, 32)}, + {"legacy-uuid", append([]byte("GOMODFS\x01\x01"), make([]byte, 16)...)}, + {"bad-magic", append([]byte("GOMODFX\x01\x01"), make([]byte, 32)...)}, + {"truncated-magic", []byte("GOMODF")}, + {"missing-version", []byte("GOMODFS")}, + {"missing-kind", []byte("GOMODFS\x01")}, + {"unknown-version", append([]byte("GOMODFS\x02\x01"), make([]byte, 32)...)}, + {"unknown-kind", append([]byte("GOMODFS\x01\x02"), make([]byte, 32)...)}, + {"tagged-module", append([]byte("GOMODFS\x01\x00"), make([]byte, 32)...)}, + {"missing-payload", []byte("GOMODFS\x01\x01")}, + {"short-payload", append([]byte("GOMODFS\x01\x01"), make([]byte, 31)...)}, + {"long-payload", append([]byte("GOMODFS\x01\x01"), make([]byte, 33)...)}, + {"max-tagged-length-bad-payload", append([]byte("GOMODFS\x01\x01"), make([]byte, 54)...)}, + {"overlong", append([]byte("GOMODFS\x01\x01"), make([]byte, 56)...)}, + {"bad-module-sentinel", []byte("\x00\xFF\x00Nope!")}, + } { + t.Run(tt.name, func(t *testing.T) { + _, _, err := decodeHandle(tt.handle) + checkStaleHandle(t, err) + }) + } + + h := &handler{} + handle := []byte("invalid") + _, _, err := h.FromHandle(handle) + checkStaleHandle(t, err) + _, err = h.OnNFSRead(t.Context(), handle, 0, 6) + checkStaleHandle(t, err) + checkStaleHandle(t, h.InvalidateHandle(nil, handle)) +} + +func TestNFSExportsSharedListener(t *testing.T) { + repos, upstream, sha := testRepositoryHandler(t) + acquireRepository(t, repos, sha) + exportGit(t, upstream, "commit", "--allow-empty", "-qm", "second") + secondSHA := exportGit(t, upstream, "rev-parse", "HEAD") + acquireRepository(t, repos, secondSHA) + modules := &gomodfs.FS{} + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + server := &nfs.Server{ + Handler: New(modules, repos), + } + go server.Serve(ln) + port := ln.Addr().(*net.TCPAddr).Port + client, err := clientnfs.DialServiceAtPort("127.0.0.1", port) + if err != nil { + t.Fatal(err) + } + defer client.Close() + mounter := &clientnfs.Mount{ + Client: client, + } + if _, err := mounter.Mount("/repos/unknown", clientrpc.AuthNull); err == nil { + t.Fatal("unknown checkout mount succeeded") + } + var targets []*clientnfs.Target + for _, p := range []string{"/modfs", "/repos/example/repo/" + sha, "/repos/example/repo/" + secondSHA} { + target, err := mounter.Mount(p, clientrpc.AuthNull) + if err != nil { + t.Fatalf("mount %s: %v", p, err) + } + defer target.Close() + targets = append(targets, target) + } + read := func(target *clientnfs.Target, p string) string { + t.Helper() + f, err := target.Open(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + return string(b) + } + for i, commit := range []string{sha, secondSHA} { + target := targets[i+1] + entries, err := target.ReadDirPlus("") + if err != nil || len(entries) == 0 { + t.Fatalf("readdir: %v, %v", entries, err) + } + if got := read(target, ".git/HEAD"); got != commit+"\n" { + t.Errorf("HEAD = %q; want %q", got, commit) + } + if got := read(target, "hello.txt"); got != "hello\n" { + t.Errorf("hello = %q", got) + } + } + if got := read(targets[0], ".gomodfs-status"); !strings.Contains(got, `"filesystem": "gomodfs"`) { + t.Errorf("module file = %q", got) + } +} + +type fakeConn struct{ net.Conn } + +func (fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } diff --git a/testing/ci/ci_test.go b/testing/ci/ci_test.go index d86adfd..29ba99b 100644 --- a/testing/ci/ci_test.go +++ b/testing/ci/ci_test.go @@ -4,15 +4,24 @@ package ci import ( + "debug/buildinfo" "encoding/json" "flag" "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "testing" "github.com/tailscale/gomodfs" ) -var runVerifyUsed = flag.Bool("verify-used", false, "if set, runs TestVerifyUsed") +var ( + runVerifyUsed = flag.Bool("verify-used", false, "if set, runs TestVerifyUsed") + runRepositoryMount = flag.Bool("repository-mount", false, "verify the CI NFS repository mount") +) func TestVerifyUsed(t *testing.T) { if !*runVerifyUsed { @@ -51,3 +60,92 @@ func TestVerifyUsed(t *testing.T) { // TODO: gracefully shut down the server? meh. CI will clean up. } + +func TestRepositoryMount(t *testing.T) { + if !*runRepositoryMount { + t.Skip("only runs in CI with -repository-mount") + } + dir, commit := os.Getenv("GOMODFS_REPO"), os.Getenv("GOMODFS_REPO_COMMIT") + if dir == "" || len(commit) != 40 { + t.Fatal("GOMODFS_REPO and GOMODFS_REPO_COMMIT must identify the mounted fixture") + } + // The example server's fixed owner needn't match the CI runner. + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "safe.directory") + t.Setenv("GIT_CONFIG_VALUE_0", filepath.ToSlash(dir)) + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "") + t.Setenv("GOTOOLCHAIN", "local") + run := func(name string, args ...string) string { + t.Helper() + cmd := exec.CommandContext(t.Context(), name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %q: %v\n%s", name, args, err, out) + } + return strings.TrimSpace(string(out)) + } + // Status can refresh the index and hide incorrect initial stat data. + run("git", "diff-index", "--quiet", "HEAD") + if got := run("git", "status", "--porcelain"); got != "" { + t.Fatalf("dirty checkout: %s", got) + } + if got := run("git", "rev-parse", "HEAD"); got != commit { + t.Fatalf("HEAD = %q; want %q", got, commit) + } + if got := run("git", "log", "-1", "--format=%ct"); got != "1704164645" { + t.Fatalf("commit timestamp = %q", got) + } + if got := run("git", "grep", "-n", "nfs-repository-fixture", "--", "main.go"); !strings.Contains(got, "main.go:3:") { + t.Fatalf("unexpected grep output: %q", got) + } + tree := run("git", "ls-tree", "HEAD") + for _, name := range []string{"go.mod", "main.go", "run.sh"} { + if !strings.Contains(tree, "\t"+name) { + t.Errorf("ls-tree missing %s: %s", name, tree) + } + } + if got := run("git", "ls-tree", "HEAD", "run.sh"); !strings.HasPrefix(got, "100755 blob ") { + t.Errorf("run.sh is not executable: %s", got) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(filepath.Join(dir, "run.sh")) + if err != nil || info.Mode()&0111 == 0 { + t.Fatalf("mounted executable mode: %v, %v", info, err) + } + if strings.Contains(tree, "\tmain.link") { + if target, err := os.Readlink(filepath.Join(dir, "main.link")); err != nil || target != "main.go" { + t.Fatalf("mounted symlink = %q, %v", target, err) + } + } + } + + binary := filepath.Join(t.TempDir(), "fixture.exe") + run("go", "build", "-buildvcs=true", "-o", binary, ".") + info, err := buildinfo.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + settings := map[string]string{} + for _, setting := range info.Settings { + settings[setting.Key] = setting.Value + } + for key, want := range map[string]string{ + "vcs": "git", + "vcs.revision": commit, + "vcs.time": "2024-01-02T03:04:05Z", + "vcs.modified": "false", + } { + if got := settings[key]; got != want { + t.Errorf("build setting %s = %q; want %q", key, got, want) + } + } + for _, name := range []string{"main.go", ".git/index", ".git/index.lock"} { + f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + if err == nil { + f.Close() + t.Errorf("opened %s for writing on read-only checkout", name) + } + } +} diff --git a/testing/nfsmount/nfsmount.go b/testing/nfsmount/nfsmount.go index 232aba2..95fbc45 100644 --- a/testing/nfsmount/nfsmount.go +++ b/testing/nfsmount/nfsmount.go @@ -1,14 +1,12 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -// The startgomodfs binary is used in CI tests to start a gomodfs server on -// Windows, because Powershell-in-YAML-in-Github-Actions with shell quoting -// is hard. But then for consistency it also does Linux & macOS, even though -// those are trivial from YAML. +// Mount commands live here because shell quoting across CI platforms is hard. package main import ( "context" + "flag" "fmt" "log" "net" @@ -21,8 +19,23 @@ import ( ) func main() { + repoExportFile := flag.String("repo-export-file", "", "file containing a repository export path to mount instead of the module cache") + flag.Parse() if os.Getenv("CI") != "true" { - log.Fatalf("startgomodfs is only intended to be run in CI") + log.Fatalf("nfsmount is only intended to be run in CI") + } + + export, mountName, envName := "/gomodfs", "gomodfs-mnt", "GOMODCACHE" + if *repoExportFile != "" { + b, err := os.ReadFile(*repoExportFile) + if err != nil { + log.Fatal(err) + } + export = strings.TrimSpace(string(b)) + if !strings.HasPrefix(export, "/repos/") { + log.Fatalf("invalid repository export %q", export) + } + mountName, envName = "gomodfs-repo-mnt", "GOMODFS_REPO" } var cmd *exec.Cmd @@ -30,7 +43,7 @@ func main() { var machineIP = "127.0.0.1" useTempMnt := func() { - mntDir = filepath.Join(os.TempDir(), "gomodfs-mnt") + mntDir = filepath.Join(os.TempDir(), mountName) if err := os.MkdirAll(mntDir, 0755); err != nil { log.Fatalf("creating mount dir %s: %v", mntDir, err) } @@ -80,6 +93,9 @@ func main() { switch runtime.GOOS { case "windows": mntDir = "Z:" + if *repoExportFile != "" { + mntDir = "Y:" + } cmd = exec.Command("mount.exe", "-o", "anon", "-o", "mtype=hard", @@ -87,14 +103,14 @@ func main() { // TODO: add "-o", "nolock" once we send a fix to cmd/go // to upstream Go to not require RLock to pass on file-readonly // filesystems. - `\\127.0.0.1\modfs`, + `\\127.0.0.1`+strings.ReplaceAll(export, "/", `\`), mntDir) case "linux": useTempMnt() cmd = exec.Command("sudo", "/usr/bin/mount", "-t", "nfs", "-o", linuxNFSMountOpts(2049), - machineIP+":/gomodfs", + machineIP+":"+export, mntDir, ) case "darwin": @@ -102,7 +118,7 @@ func main() { cmd = exec.Command("/sbin/mount", "-t", "nfs", "-o", darwinNFSMountOpts(2049), - machineIP+":/gomodfs", + machineIP+":"+export, mntDir, ) default: @@ -163,11 +179,15 @@ func main() { if runtime.GOOS == "windows" && strings.HasSuffix(mcDir, ":") { mcDir += "\\" } - err := appendFile(e, fmt.Appendf(nil, "GOMODCACHE=%s\n", mcDir), 0644) + data := fmt.Appendf(nil, "%s=%s\n", envName, mcDir) + if *repoExportFile != "" { + data = fmt.Appendf(data, "GOMODFS_REPO_COMMIT=%s\n", filepath.Base(export)) + } + err := appendFile(e, data, 0644) if err != nil { - log.Fatalf("writing GOMODFS to GITHUB_ENV file %q: %v", e, err) + log.Fatalf("writing %s to GITHUB_ENV file %q: %v", envName, e, err) } - log.Printf("set env GOMODCACHE=%s", mcDir) + log.Printf("set env %s=%s", envName, mcDir) } } diff --git a/testing/startgomodfs/fixture.go b/testing/startgomodfs/fixture.go new file mode 100644 index 0000000..114d739 --- /dev/null +++ b/testing/startgomodfs/fixture.go @@ -0,0 +1,59 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func createRepoFixture(dir string) (string, error) { + git := func(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2024-01-02T03:04:05Z", "GIT_COMMITTER_DATE=2024-01-02T03:04:05Z") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %q: %w: %s", args, err, out) + } + return strings.TrimSpace(string(out)), nil + } + for _, args := range [][]string{ + {"init", "--initial-branch=main", "--object-format=sha1"}, + {"config", "user.name", "NFS CI"}, + {"config", "user.email", "nfs-ci@example.com"}, + {"config", "core.autocrlf", "false"}, + } { + if _, err := git(args...); err != nil { + return "", err + } + } + for name, content := range map[string]string{ + "go.mod": "module example.com/nfsfixture\n\ngo 1.23.0\n", + "main.go": "package main\n\nfunc main() { println(\"nfs-repository-fixture\") }\n", + "run.sh": "#!/bin/sh\necho nfs-repository-fixture\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + return "", err + } + } + if err := os.Chmod(filepath.Join(dir, "run.sh"), 0755); err != nil { + return "", err + } + // Windows runners may not grant the privilege required to create symlinks. + _ = os.Symlink("main.go", filepath.Join(dir, "main.link")) + for _, args := range [][]string{ + {"add", "."}, + {"update-index", "--chmod=+x", "run.sh"}, + {"-c", "commit.gpgsign=false", "commit", "-m", "NFS repository fixture"}, + } { + if _, err := git(args...); err != nil { + return "", err + } + } + return git("rev-parse", "HEAD") +} diff --git a/testing/startgomodfs/startgomodfs.go b/testing/startgomodfs/startgomodfs.go index 9d4fc03..6f8e576 100644 --- a/testing/startgomodfs/startgomodfs.go +++ b/testing/startgomodfs/startgomodfs.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "reflect" "runtime" "syscall" @@ -53,6 +54,25 @@ func main() { } } + if !*useWinFSP { + dir, err := os.MkdirTemp("", "gomodfs-repo-fixture-") + if err != nil { + log.Fatal(err) + } + repoDir := filepath.Join(dir, "example", "repo") + if err := os.MkdirAll(repoDir, 0755); err != nil { + log.Fatal(err) + } + commit, err := createRepoFixture(repoDir) + if err != nil { + log.Fatal(err) + } + if err := os.WriteFile("gomodfs-repo-export", []byte("/repos/example/repo/"+commit), 0644); err != nil { + log.Fatal(err) + } + cmd.Args = append(cmd.Args, "-repo=file://"+filepath.ToSlash(repoDir), "-commit="+commit) + } + cmd.Stdout = f cmd.Stderr = f if runtime.GOOS != "windows" {