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" {