fix: race condition, broken tests (#203)

Carlos Alexandro Becker and dependabot[bot] created

* test: fixing broken tests

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* fix: race daemon

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* refactor: rename, godocs

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* test: improve git test

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* fix: improvements

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

* feat(deps): bump github.com/go-git/go-billy/v5 from 5.3.1 to 5.4.0

Bumps [github.com/go-git/go-billy/v5](https://github.com/go-git/go-billy) from 5.3.1 to 5.4.0.
- [Release notes](https://github.com/go-git/go-billy/releases)
- [Commits](https://github.com/go-git/go-billy/compare/v5.3.1...v5.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-billy/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* test: fix more tests

* fix: gitignore

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>

Signed-off-by: Carlos A Becker <caarlos0@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

Change summary

.gitignore                       |   3 
config/auth.go                   | 158 --------
config/auth_test.go              | 669 ----------------------------------
config/config.go                 | 338 -----------------
config/config_test.go            |  35 -
config/defaults.go               |  58 --
config/git.go                    | 299 ---------------
go.mod                           |   4 
go.sum                           |   6 
server/config/config.go          |   5 
server/config/testdata/k1.pub    |   0 
server/git/daemon/daemon.go      |  51 ++
server/git/daemon/daemon_test.go |  12 
server/server_test.go            |  45 -
14 files changed, 74 insertions(+), 1,609 deletions(-)

Detailed changes

.gitignore 🔗

@@ -1,6 +1,5 @@
 soft
 data
 dist
-testdata
 completions/
-manpages/
+manpages/

config/auth.go 🔗

@@ -1,158 +0,0 @@
-package config
-
-import (
-	"log"
-	"strings"
-
-	"github.com/charmbracelet/soft-serve/proto"
-	"github.com/gliderlabs/ssh"
-	gossh "golang.org/x/crypto/ssh"
-)
-
-// Push registers Git push functionality for the given repo and key.
-func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
-	go func() {
-		err := cfg.Reload()
-		if err != nil {
-			log.Printf("error reloading after push: %s", err)
-		}
-		if cfg.Cfg.Callbacks != nil {
-			cfg.Cfg.Callbacks.Push(repo)
-		}
-		r, err := cfg.Source.GetRepo(repo)
-		if err != nil {
-			log.Printf("error getting repo after push: %s", err)
-			return
-		}
-		err = r.UpdateServerInfo()
-		if err != nil {
-			log.Printf("error updating server info after push: %s", err)
-		}
-	}()
-}
-
-// Fetch registers Git fetch functionality for the given repo and key.
-func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) {
-	if cfg.Cfg.Callbacks != nil {
-		cfg.Cfg.Callbacks.Fetch(repo)
-	}
-}
-
-// AuthRepo grants repo authorization to the given key.
-func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) proto.AccessLevel {
-	return cfg.accessForKey(repo, pk)
-}
-
-// PasswordHandler returns whether or not password access is allowed.
-func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool {
-	return (cfg.AnonAccess != proto.NoAccess.String()) && cfg.AllowKeyless
-}
-
-// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed.
-func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
-	return (cfg.AnonAccess != proto.NoAccess.String()) && cfg.AllowKeyless
-}
-
-// PublicKeyHandler returns whether or not the given public key may access the
-// repo.
-func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
-	return cfg.accessForKey("", pk) != proto.NoAccess
-}
-
-func (cfg *Config) anonAccessLevel() proto.AccessLevel {
-	cfg.mtx.RLock()
-	defer cfg.mtx.RUnlock()
-	switch cfg.AnonAccess {
-	case "no-access":
-		return proto.NoAccess
-	case "read-only":
-		return proto.ReadOnlyAccess
-	case "read-write":
-		return proto.ReadWriteAccess
-	case "admin-access":
-		return proto.AdminAccess
-	default:
-		return proto.NoAccess
-	}
-}
-
-// accessForKey returns the access level for the given repo.
-//
-// If repo doesn't exist, then access is based on user's admin privileges, or
-// config.AnonAccess.
-// If repo exists, and private, then admins and collabs are allowed access.
-// If repo exists, and not private, then access is based on config.AnonAccess.
-func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) proto.AccessLevel {
-	anon := cfg.anonAccessLevel()
-	private := cfg.isPrivate(repo)
-	// Find user
-	if pk != nil {
-		for _, user := range cfg.Users {
-			for _, k := range user.PublicKeys {
-				apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k)))
-				if err != nil {
-					log.Printf("error: malformed authorized key: '%s'", k)
-					return proto.NoAccess
-				}
-				if ssh.KeysEqual(pk, apk) {
-					if user.Admin {
-						return proto.AdminAccess
-					}
-					u := user
-					if cfg.isCollab(repo, &u) {
-						if anon > proto.ReadWriteAccess {
-							return anon
-						}
-						return proto.ReadWriteAccess
-					}
-					if !private {
-						if anon > proto.ReadOnlyAccess {
-							return anon
-						}
-						return proto.ReadOnlyAccess
-					}
-				}
-			}
-		}
-	}
-	// Don't restrict access to private repos if no users are configured.
-	// Return anon access level.
-	if private && len(cfg.Users) > 0 {
-		return proto.NoAccess
-	}
-	return anon
-}
-
-func (cfg *Config) findRepo(repo string) *RepoConfig {
-	for _, r := range cfg.Repos {
-		if r.Repo == repo {
-			return &r
-		}
-	}
-	return nil
-}
-
-func (cfg *Config) isPrivate(repo string) bool {
-	if r := cfg.findRepo(repo); r != nil {
-		return r.Private
-	}
-	return false
-}
-
-func (cfg *Config) isCollab(repo string, user *User) bool {
-	if user != nil {
-		for _, r := range user.CollabRepos {
-			if r == repo {
-				return true
-			}
-		}
-		if r := cfg.findRepo(repo); r != nil {
-			for _, c := range r.Collabs {
-				if c == user.Name {
-					return true
-				}
-			}
-		}
-	}
-	return false
-}

config/auth_test.go 🔗

@@ -1,669 +0,0 @@
-package config
-
-import (
-	"testing"
-
-	"github.com/charmbracelet/soft-serve/proto"
-	"github.com/gliderlabs/ssh"
-	"github.com/matryer/is"
-)
-
-func TestAuth(t *testing.T) {
-	adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b"
-	adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey))
-	dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b"
-	dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey))
-	cases := []struct {
-		name   string
-		cfg    Config
-		repo   string
-		key    ssh.PublicKey
-		access proto.AccessLevel
-	}{
-		// Repo access
-		{
-			name:   "anon access: no-access, anonymous user",
-			access: proto.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, anonymous user with admin user",
-			access: proto.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, anonymous user with admin user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.NoAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, anonymous user",
-			repo:   "foo",
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, anonymous user",
-			repo:   "foo",
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: read-write, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user",
-			repo:   "foo",
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: admin-access, admin user",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-					},
-				},
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// Collabs
-		{
-			name:   "anon access: no-access, authd user, collab",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, collab, private repo",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user, collab, private repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name:  "admin",
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user, collab, private repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Repos: []RepoConfig{
-					{
-						Repo:    "foo",
-						Private: true,
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user, collab",
-			repo:   "foo",
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user, collab",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name: "user",
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		}, {
-			name:   "anon access: admin-access, admin user, collab",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Repos: []RepoConfig{
-					{
-						Repo: "foo",
-						Collabs: []string{
-							"user",
-						},
-					},
-				},
-				Users: []User{
-					{
-						Name:  "admin",
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// New repo
-		{
-			name:   "anon access: no-access, anonymous user, new repo",
-			access: proto.NoAccess,
-			repo:   "foo",
-			cfg: Config{
-				AnonAccess: "no-access",
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, new repo",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, authd user, new repo, with user",
-			key:    dummyPk,
-			repo:   "foo",
-			access: proto.NoAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: no-access, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "no-access",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, anonymous user, new repo",
-			repo:   "foo",
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-			},
-		},
-		{
-			name:   "anon access: read-only, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-only, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, anonymous user, new repo",
-			repo:   "foo",
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-			},
-		},
-		{
-			name:   "anon access: read-write, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: read-write, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, anonymous user, new repo",
-			repo:   "foo",
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-			},
-		},
-		{
-			name:   "anon access: admin-access, authd user, new repo",
-			repo:   "foo",
-			key:    dummyPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Users: []User{
-					{
-						PublicKeys: []string{
-							dummyKey,
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "anon access: admin-access, admin user, new repo",
-			repo:   "foo",
-			key:    adminPk,
-			access: proto.AdminAccess,
-			cfg: Config{
-				AnonAccess: "admin-access",
-				Users: []User{
-					{
-						Admin: true,
-						PublicKeys: []string{
-							adminKey,
-						},
-					},
-				},
-			},
-		},
-
-		// No users
-		{
-			name:   "anon access: read-only, no users",
-			repo:   "foo",
-			access: proto.ReadOnlyAccess,
-			cfg: Config{
-				AnonAccess: "read-only",
-			},
-		},
-		{
-			name:   "anon access: read-write, no users",
-			repo:   "foo",
-			access: proto.ReadWriteAccess,
-			cfg: Config{
-				AnonAccess: "read-write",
-			},
-		},
-	}
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			is := is.New(t)
-			al := c.cfg.accessForKey(c.repo, c.key)
-			is.Equal(al, c.access)
-		})
-	}
-}

config/config.go 🔗

@@ -1,338 +0,0 @@
-package config
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"io/fs"
-	"log"
-	"path/filepath"
-	"strings"
-	"sync"
-	"text/template"
-	"time"
-
-	"golang.org/x/crypto/ssh"
-	"gopkg.in/yaml.v3"
-
-	"fmt"
-	"os"
-
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/proto"
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/go-git/go-billy/v5/memfs"
-	ggit "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing/object"
-	"github.com/go-git/go-git/v5/plumbing/transport"
-	"github.com/go-git/go-git/v5/storage/memory"
-)
-
-var (
-	// ErrNoConfig is returned when a repo has no config file.
-	ErrNoConfig = errors.New("no config file found")
-)
-
-const (
-	defaultConfigRepo = "config"
-)
-
-// Config is the Soft Serve configuration.
-type Config struct {
-	Name         string         `yaml:"name" json:"name"`
-	Host         string         `yaml:"host" json:"host"`
-	Port         int            `yaml:"port" json:"port"`
-	AnonAccess   string         `yaml:"anon-access" json:"anon-access"`
-	AllowKeyless bool           `yaml:"allow-keyless" json:"allow-keyless"`
-	Users        []User         `yaml:"users" json:"users"`
-	Repos        []RepoConfig   `yaml:"repos" json:"repos"`
-	Source       *RepoSource    `yaml:"-" json:"-"`
-	Cfg          *config.Config `yaml:"-" json:"-"`
-	mtx          sync.RWMutex
-}
-
-// User contains user-level configuration for a repository.
-type User struct {
-	Name        string   `yaml:"name" json:"name"`
-	Admin       bool     `yaml:"admin" json:"admin"`
-	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
-	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
-}
-
-// RepoConfig is a repository configuration.
-type RepoConfig struct {
-	Name    string   `yaml:"name" json:"name"`
-	Repo    string   `yaml:"repo" json:"repo"`
-	Note    string   `yaml:"note" json:"note"`
-	Private bool     `yaml:"private" json:"private"`
-	Readme  string   `yaml:"readme" json:"readme"`
-	Collabs []string `yaml:"collabs" json:"collabs"`
-}
-
-// NewConfig creates a new internal Config struct.
-func NewConfig(cfg *config.Config) (*Config, error) {
-	var anonAccess string
-	var yamlUsers string
-	var displayHost string
-	host := cfg.Host
-	port := cfg.SSH.Port
-
-	pks := make([]string, 0)
-	for _, k := range cfg.InitialAdminKeys {
-		if bts, err := os.ReadFile(k); err == nil {
-			// pk is a file, set its contents as pk
-			k = string(bts)
-		}
-		var pk = strings.TrimSpace(k)
-		if pk == "" {
-			continue
-		}
-		// it is a valid ssh key, nothing to do
-		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
-			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
-		}
-		pks = append(pks, pk)
-	}
-
-	rs := NewRepoSource(cfg.RepoPath())
-	c := &Config{
-		Cfg: cfg,
-	}
-	c.Host = host
-	c.Port = port
-	c.Source = rs
-	// Grant read-write access when no keys are provided.
-	if len(pks) == 0 {
-		anonAccess = proto.ReadWriteAccess.String()
-	} else {
-		anonAccess = proto.ReadOnlyAccess.String()
-	}
-	if host == "" {
-		displayHost = "localhost"
-	} else {
-		displayHost = host
-	}
-	yamlConfig := fmt.Sprintf(defaultConfig,
-		displayHost,
-		port,
-		anonAccess,
-		len(pks) == 0,
-	)
-	if len(pks) == 0 {
-		yamlUsers = defaultUserConfig
-	} else {
-		var result string
-		for _, pk := range pks {
-			result += fmt.Sprintf("      - %s\n", pk)
-		}
-		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
-	}
-	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
-	err := c.createDefaultConfigRepo(yaml)
-	if err != nil {
-		return nil, err
-	}
-	return c, nil
-}
-
-// readConfig reads the config file for the repo. All config files are stored in
-// the config repo.
-func (cfg *Config) readConfig(repo string, v interface{}) error {
-	cr, err := cfg.Source.GetRepo(defaultConfigRepo)
-	if err != nil {
-		return err
-	}
-	// Parse YAML files
-	var cy string
-	for _, ext := range []string{".yaml", ".yml"} {
-		cy, _, err = cr.LatestFile(repo + ext)
-		if err != nil && !errors.Is(err, git.ErrFileNotFound) {
-			return err
-		} else if err == nil {
-			break
-		}
-	}
-	// Parse JSON files
-	cj, _, err := cr.LatestFile(repo + ".json")
-	if err != nil && !errors.Is(err, git.ErrFileNotFound) {
-		return err
-	}
-	if cy != "" {
-		err = yaml.Unmarshal([]byte(cy), v)
-		if err != nil {
-			return err
-		}
-	} else if cj != "" {
-		err = json.Unmarshal([]byte(cj), v)
-		if err != nil {
-			return err
-		}
-	} else {
-		return ErrNoConfig
-	}
-	return nil
-}
-
-// Reload reloads the configuration.
-func (cfg *Config) Reload() error {
-	cfg.mtx.Lock()
-	defer cfg.mtx.Unlock()
-	err := cfg.Source.LoadRepos()
-	if err != nil {
-		return err
-	}
-	if err := cfg.readConfig(defaultConfigRepo, cfg); err != nil {
-		return fmt.Errorf("error reading config: %w", err)
-	}
-	// sanitize repo configs
-	repos := make(map[string]RepoConfig, 0)
-	for _, r := range cfg.Repos {
-		repos[r.Repo] = r
-	}
-	for _, r := range cfg.Source.AllRepos() {
-		var rc RepoConfig
-		repo := r.Repo()
-		if repo == defaultConfigRepo {
-			continue
-		}
-		if err := cfg.readConfig(repo, &rc); err != nil {
-			if !errors.Is(err, ErrNoConfig) {
-				log.Printf("error reading config: %v", err)
-			}
-			continue
-		}
-		repos[r.Repo()] = rc
-	}
-	cfg.Repos = make([]RepoConfig, 0, len(repos))
-	for n, r := range repos {
-		r.Repo = n
-		cfg.Repos = append(cfg.Repos, r)
-	}
-	// Populate readmes and descriptions
-	for _, r := range cfg.Source.AllRepos() {
-		repo := r.Repo()
-		err = r.UpdateServerInfo()
-		if err != nil {
-			log.Printf("error updating server info for %s: %s", repo, err)
-		}
-		pat := "README*"
-		rp := ""
-		for _, rr := range cfg.Repos {
-			if repo == rr.Repo {
-				rp = rr.Readme
-				r.name = rr.Name
-				r.description = rr.Note
-				r.private = rr.Private
-				break
-			}
-		}
-		if rp != "" {
-			pat = rp
-		}
-		rm := ""
-		fc, fp, _ := r.LatestFile(pat)
-		rm = fc
-		if repo == "config" {
-			md, err := templatize(rm, cfg)
-			if err != nil {
-				return err
-			}
-			rm = md
-		}
-		r.SetReadme(rm, fp)
-	}
-	return nil
-}
-
-func createFile(path string, content string) error {
-	f, err := os.Create(path)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-	_, err = f.WriteString(content)
-	if err != nil {
-		return err
-	}
-	return f.Sync()
-}
-
-func (cfg *Config) createDefaultConfigRepo(yaml string) error {
-	cn := defaultConfigRepo
-	rp := filepath.Join(cfg.Cfg.RepoPath(), cn) + ".git"
-	rs := cfg.Source
-	err := rs.LoadRepo(cn)
-	if errors.Is(err, fs.ErrNotExist) {
-		repo, err := ggit.PlainInit(rp, true)
-		if err != nil {
-			return err
-		}
-		repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
-			URL: rp,
-		})
-		if err != nil && err != transport.ErrEmptyRemoteRepository {
-			return err
-		}
-		wt, err := repo.Worktree()
-		if err != nil {
-			return err
-		}
-		rm, err := wt.Filesystem.Create("README.md")
-		if err != nil {
-			return err
-		}
-		_, err = rm.Write([]byte(defaultReadme))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("README.md")
-		if err != nil {
-			return err
-		}
-		cf, err := wt.Filesystem.Create("config.yaml")
-		if err != nil {
-			return err
-		}
-		_, err = cf.Write([]byte(yaml))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("config.yaml")
-		if err != nil {
-			return err
-		}
-		author := object.Signature{
-			Name:  "Soft Serve Server",
-			Email: "vt100@charm.sh",
-			When:  time.Now(),
-		}
-		_, err = wt.Commit("Default init", &ggit.CommitOptions{
-			All:       true,
-			Author:    &author,
-			Committer: &author,
-		})
-		if err != nil {
-			return err
-		}
-		err = repo.Push(&ggit.PushOptions{})
-		if err != nil {
-			return err
-		}
-	} else if err != nil {
-		return err
-	}
-	return cfg.Reload()
-}
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
-	t, err := template.New("readme").Parse(mdt)
-	if err != nil {
-		return "", err
-	}
-	buf := &bytes.Buffer{}
-	err = t.Execute(buf, tmpl)
-	if err != nil {
-		return "", err
-	}
-	return buf.String(), nil
-}

config/config_test.go 🔗

@@ -1,35 +0,0 @@
-package config
-
-import (
-	"testing"
-
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/matryer/is"
-)
-
-func TestMultipleInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		DataPath: t.TempDir(),
-		InitialAdminKeys: []string{
-			"testdata/k1.pub",
-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-		},
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	err = cfg.Reload()
-	is.NoErr(err)
-	is.Equal(cfg.Users[0].PublicKeys, []string{
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-	}) // should have both keys
-}
-
-func TestEmptyInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		DataPath: t.TempDir(),
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	is.Equal(len(cfg.Users), 0) // should not have any users
-}

config/defaults.go 🔗

@@ -1,58 +0,0 @@
-package config
-
-const defaultReadme = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://{{.Host}}:{{.Port}}/config\n```"
-
-const defaultConfig = `# The name of the server to show in the TUI.
-name: Soft Serve
-
-# The host and port to display in the TUI. You may want to change this if your
-# server is accessible from a different host and/or port that what it's
-# actually listening on (for example, if it's behind a reverse proxy).
-host: %s
-port: %d
-
-# Access level for anonymous users. Options are: admin-access, read-write,
-# read-only, and no-access.
-anon-access: %s
-
-# You can grant read-only access to users without private keys. Any password
-# will be accepted.
-allow-keyless: %t
-
-# Customize repo display in the menu.
-repos:
-  - name: Home
-    repo: config
-    private: true
-    note: "Configuration and content repo for this server"
-    readme: README.md
-`
-
-const hasKeyUserConfig = `
-
-# Authorized users. Admins have full access to all repos. Private repos are only
-# accessible by admins and collab users. Regular users can read public repos
-# based on your anon-access setting.
-users:
-  - name: Admin
-    admin: true
-    public-keys:
-%s
-`
-
-const defaultUserConfig = `
-#users:
-#  - name: Admin
-#    admin: true
-#    public-keys:
-#      - ssh-ed25519 AAAA... # redacted
-#      - ssh-rsa AAAAB3Nz... # redacted`
-
-const exampleUserConfig = `
-#  - name: Example User
-#    collab-repos:
-#      - REPO
-#    public-keys:
-#      - ssh-ed25519 AAAA... # redacted
-#      - ssh-rsa AAAAB3Nz... # redacted
-`

config/git.go 🔗

@@ -1,299 +0,0 @@
-package config
-
-import (
-	"errors"
-	"log"
-	"os"
-	"path/filepath"
-	"strings"
-	"sync"
-
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/gobwas/glob"
-	"github.com/golang/groupcache/lru"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-var ErrMissingRepo = errors.New("missing repo")
-
-// Repo represents a Git repository.
-type Repo struct {
-	name        string
-	description string
-	path        string
-	repository  *git.Repository
-	readme      string
-	readmePath  string
-	head        *git.Reference
-	headCommit  string
-	refs        []*git.Reference
-	patchCache  *lru.Cache
-	private     bool
-}
-
-// open opens a Git repository.
-func (rs *RepoSource) open(path string) (*Repo, error) {
-	rg, err := git.Open(path)
-	if err != nil {
-		return nil, err
-	}
-	r := &Repo{
-		path:       path,
-		repository: rg,
-		patchCache: lru.New(1000),
-	}
-	_, err = r.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	_, err = r.References()
-	if err != nil {
-		return nil, err
-	}
-	return r, nil
-}
-
-// IsBare returns true if the repository is a bare repository.
-func (r *Repo) IsBare() bool {
-	return r.repository.IsBare
-}
-
-// IsPrivate returns true if the repository is private.
-func (r *Repo) IsPrivate() bool {
-	return r.private
-}
-
-// Path returns the path to the repository.
-func (r *Repo) Path() string {
-	return r.path
-}
-
-// Repo returns the repository directory name.
-func (r *Repo) Repo() string {
-	return strings.TrimSuffix(filepath.Base(r.path), ".git")
-}
-
-// Name returns the name of the repository.
-func (r *Repo) Name() string {
-	if r.name == "" {
-		return r.Repo()
-	}
-	return r.name
-}
-
-// Description returns the description for a repository.
-func (r *Repo) Description() string {
-	return r.description
-}
-
-// Readme returns the readme and its path for the repository.
-func (r *Repo) Readme() (readme string, path string) {
-	return r.readme, r.readmePath
-}
-
-// SetReadme sets the readme for the repository.
-func (r *Repo) SetReadme(readme, path string) {
-	r.readme = readme
-	r.readmePath = path
-}
-
-// HEAD returns the reference for a repository.
-func (r *Repo) HEAD() (*git.Reference, error) {
-	if r.head != nil {
-		return r.head, nil
-	}
-	h, err := r.repository.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	r.head = h
-	return h, nil
-}
-
-// GetReferences returns the references for a repository.
-func (r *Repo) References() ([]*git.Reference, error) {
-	if r.refs != nil {
-		return r.refs, nil
-	}
-	refs, err := r.repository.References()
-	if err != nil {
-		return nil, err
-	}
-	r.refs = refs
-	return refs, nil
-}
-
-// Tree returns the git tree for a given path.
-func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
-	return r.repository.TreePath(ref, path)
-}
-
-// Diff returns the diff for a given commit.
-func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) {
-	hash := commit.Hash.String()
-	c, ok := r.patchCache.Get(hash)
-	if ok {
-		return c.(*git.Diff), nil
-	}
-	diff, err := r.repository.Diff(commit)
-	if err != nil {
-		return nil, err
-	}
-	r.patchCache.Add(hash, diff)
-	return diff, nil
-}
-
-// CountCommits returns the number of commits for a repository.
-func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
-	tc, err := r.repository.CountCommits(ref)
-	if err != nil {
-		return 0, err
-	}
-	return tc, nil
-}
-
-// Commit returns the commit for a given hash.
-func (r *Repo) Commit(hash string) (*git.Commit, error) {
-	if hash == "HEAD" && r.headCommit != "" {
-		hash = r.headCommit
-	}
-	c, err := r.repository.CatFileCommit(hash)
-	if err != nil {
-		return nil, err
-	}
-	r.headCommit = c.ID.String()
-	return &git.Commit{
-		Commit: c,
-		Hash:   git.Hash(c.ID.String()),
-	}, nil
-}
-
-// CommitsByPage returns the commits for a repository.
-func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
-	return r.repository.CommitsByPage(ref, page, size)
-}
-
-// Push pushes the repository to the remote.
-func (r *Repo) Push(remote, branch string) error {
-	return r.repository.Push(remote, branch)
-}
-
-// RepoSource is a reference to an on-disk repositories.
-type RepoSource struct {
-	Path  string
-	mtx   sync.Mutex
-	repos map[string]*Repo
-}
-
-// NewRepoSource creates a new RepoSource.
-func NewRepoSource(repoPath string) *RepoSource {
-	err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
-	if err != nil {
-		log.Fatal(err)
-	}
-	rs := &RepoSource{Path: repoPath}
-	rs.repos = make(map[string]*Repo, 0)
-	return rs
-}
-
-// AllRepos returns all repositories for the given RepoSource.
-func (rs *RepoSource) AllRepos() []*Repo {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	repos := make([]*Repo, 0, len(rs.repos))
-	for _, r := range rs.repos {
-		repos = append(repos, r)
-	}
-	return repos
-}
-
-// GetRepo returns a repository by name.
-func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	if strings.HasSuffix(name, ".git") {
-		name = strings.TrimSuffix(name, ".git")
-	}
-	r, ok := rs.repos[name]
-	if !ok {
-		return nil, ErrMissingRepo
-	}
-	return r, nil
-}
-
-// LoadRepo loads a repository from disk.
-func (rs *RepoSource) LoadRepo(name string) error {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	if strings.HasSuffix(name, ".git") {
-		name = strings.TrimSuffix(name, ".git")
-	}
-	rp := filepath.Join(rs.Path, name)
-	if _, err := os.Stat(rp); os.IsNotExist(err) {
-		rp += ".git"
-	}
-	r, err := rs.open(rp)
-	if err != nil {
-		return err
-	}
-	if !r.IsBare() {
-		log.Printf("warning: %q is not a bare repository", r.Path())
-	} else if r.IsBare() && !strings.HasSuffix(rp, ".git") {
-		log.Printf("warning: %q should be renamed to %q", r.Path(), r.Path()+".git")
-	}
-	rs.repos[name] = r
-	return nil
-}
-
-// LoadRepos opens Git repositories.
-func (rs *RepoSource) LoadRepos() error {
-	rd, err := os.ReadDir(rs.Path)
-	if err != nil {
-		return err
-	}
-	for _, de := range rd {
-		if !de.IsDir() {
-			log.Printf("warning: %q is not a directory", filepath.Join(rs.Path, de.Name()))
-			continue
-		}
-		if err := rs.LoadRepo(de.Name()); err != nil {
-			log.Printf("error opening repository %q: %s", de.Name(), err)
-			continue
-		}
-	}
-	return nil
-}
-
-// LatestFile returns the contents of the latest file at the specified path in
-// the repository and its file path.
-func (r *Repo) LatestFile(pattern string) (string, string, error) {
-	g := glob.MustCompile(pattern)
-	dir := filepath.Dir(pattern)
-	t, err := r.repository.TreePath(r.head, dir)
-	if err != nil {
-		return "", "", err
-	}
-	ents, err := t.Entries()
-	if err != nil {
-		return "", "", err
-	}
-	for _, e := range ents {
-		fp := filepath.Join(dir, e.Name())
-		if e.IsTree() {
-			continue
-		}
-		if g.Match(fp) {
-			bts, err := e.Contents()
-			if err != nil {
-				return "", "", err
-			}
-			return string(bts), fp, nil
-		}
-	}
-	return "", "", git.ErrFileNotFound
-}
-
-// UpdateServerInfo updates the server info for the repository.
-func (r *Repo) UpdateServerInfo() error {
-	return r.repository.UpdateServerInfo()
-}

go.mod 🔗

@@ -12,7 +12,7 @@ require (
 	github.com/charmbracelet/wish v0.7.0
 	github.com/dustin/go-humanize v1.0.0
 	github.com/gliderlabs/ssh v0.3.5
-	github.com/go-git/go-billy/v5 v5.3.1
+	github.com/go-git/go-billy/v5 v5.4.0
 	github.com/go-git/go-git/v5 v5.4.2
 	github.com/matryer/is v1.4.0
 	github.com/muesli/reflow v0.3.0
@@ -76,7 +76,7 @@ require (
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 	golang.org/x/mod v0.3.0 // indirect
 	golang.org/x/net v0.0.0-20221002022538-bcab6841153b // indirect
-	golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
+	golang.org/x/sys v0.3.0 // indirect
 	golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect

go.sum 🔗

@@ -66,8 +66,9 @@ github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4x
 github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
 github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
 github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
-github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
+github.com/go-git/go-billy/v5 v5.4.0 h1:Vaw7LaSTRJOUric7pe4vnzBSgyuf2KrLsu2Y4ZpQBDE=
+github.com/go-git/go-billy/v5 v5.4.0/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
 github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
 github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
@@ -254,8 +255,9 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
 golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=

server/config/config.go 🔗

@@ -176,7 +176,8 @@ func DefaultConfig() *Config {
 		}
 		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k)); err != nil {
 			// Fatal if the key is invalid
-			log.Fatalf("invalid initial admin key %q: %v", k, err)
+			cwd, _ := os.Getwd()
+			log.Fatalf("invalid initial admin key %q: %v", filepath.Join(cwd, k), err)
 		}
 		// store the key in the config
 		cfg.InitialAdminKeys[i] = pk
@@ -197,7 +198,7 @@ func DefaultConfig() *Config {
 		cfg.WithDB(db)
 	}
 	if err := cfg.createDefaultConfigRepoAndUsers(); err != nil {
-		log.Fatalln(err)
+		log.Fatalln("create default config and users", err)
 	}
 	return &cfg
 }

server/git/daemon/daemon.go 🔗

@@ -21,16 +21,49 @@ import (
 // ErrServerClosed indicates that the server has been closed.
 var ErrServerClosed = errors.New("git: Server closed")
 
+// connections synchronizes access to to a net.Conn pool.
+type connections struct {
+	m  map[net.Conn]struct{}
+	mu sync.Mutex
+}
+
+func (m *connections) Add(c net.Conn) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.m[c] = struct{}{}
+}
+
+func (m *connections) Close(c net.Conn) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	_ = c.Close()
+	delete(m.m, c)
+}
+
+func (m *connections) Size() int {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return len(m.m)
+}
+
+func (m *connections) CloseAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for c := range m.m {
+		_ = c.Close()
+		delete(m.m, c)
+	}
+}
+
 // Daemon represents a Git daemon.
 type Daemon struct {
 	listener net.Listener
 	addr     string
 	finished chan struct{}
-	conns    map[net.Conn]struct{}
+	conns    connections
 	cfg      *config.Config
 	wg       sync.WaitGroup
 	once     sync.Once
-	mtx      sync.RWMutex
 }
 
 // NewDaemon returns a new Git daemon.
@@ -40,7 +73,7 @@ func NewDaemon(cfg *config.Config) (*Daemon, error) {
 		addr:     addr,
 		finished: make(chan struct{}, 1),
 		cfg:      cfg,
-		conns:    make(map[net.Conn]struct{}),
+		conns:    connections{m: make(map[net.Conn]struct{})},
 	}
 	listener, err := net.Listen("tcp", d.addr)
 	if err != nil {
@@ -83,7 +116,7 @@ func (d *Daemon) Start() error {
 		}
 
 		// Close connection if there are too many open connections.
-		if len(d.conns)+1 >= d.cfg.Git.MaxConnections {
+		if d.conns.Size()+1 >= d.cfg.Git.MaxConnections {
 			log.Printf("git: max connections reached, closing %s", conn.RemoteAddr())
 			fatal(conn, git.ErrMaxConnections)
 			continue
@@ -117,10 +150,9 @@ func (d *Daemon) handleClient(conn net.Conn) {
 		dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second
 		c.maxDeadline = time.Now().Add(dur)
 	}
-	d.conns[c] = struct{}{}
+	d.conns.Add(c)
 	defer func() {
-		c.Close()
-		delete(d.conns, c)
+		d.conns.Close(c)
 	}()
 
 	readc := make(chan struct{}, 1)
@@ -194,10 +226,7 @@ func (d *Daemon) handleClient(conn net.Conn) {
 func (d *Daemon) Close() error {
 	d.once.Do(func() { close(d.finished) })
 	err := d.listener.Close()
-	for c := range d.conns {
-		c.Close()
-		delete(d.conns, c)
-	}
+	d.conns.CloseAll()
 	return err
 }
 

server/git/daemon/daemon_test.go 🔗

@@ -2,12 +2,14 @@ package daemon
 
 import (
 	"bytes"
+	"errors"
 	"io"
 	"log"
 	"net"
 	"os"
 	"strconv"
 	"testing"
+	"time"
 
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/git"
@@ -26,7 +28,7 @@ func TestMain(m *testing.M) {
 	os.Setenv("SOFT_SERVE_ANON_ACCESS", "read-only")
 	os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
 	os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
-	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "3")
+	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1")
 	os.Setenv("SOFT_SERVE_GIT_PORT", strconv.Itoa(randomPort()))
 	cfg := config.DefaultConfig()
 	d, err := NewDaemon(cfg)
@@ -39,14 +41,15 @@ func TestMain(m *testing.M) {
 			log.Fatal(err)
 		}
 	}()
-	defer d.Close()
-	os.Exit(m.Run())
+	code := m.Run()
 	os.Unsetenv("SOFT_SERVE_DATA_PATH")
 	os.Unsetenv("SOFT_SERVE_ANON_ACCESS")
 	os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
 	os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
 	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
 	os.Unsetenv("SOFT_SERVE_GIT_PORT")
+	_ = d.Close()
+	os.Exit(code)
 }
 
 func TestIdleTimeout(t *testing.T) {
@@ -54,8 +57,9 @@ func TestIdleTimeout(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+	time.Sleep(2 * time.Second)
 	out, err := readPktline(c)
-	if err != nil {
+	if err != nil && !errors.Is(err, io.EOF) {
 		t.Fatalf("expected nil, got error: %v", err)
 	}
 	if out != git.ErrTimeout.Error() {

server/server_test.go 🔗

@@ -3,7 +3,6 @@ package server
 import (
 	"fmt"
 	"net"
-	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -64,11 +63,8 @@ func TestPushRepo(t *testing.T) {
 func TestCloneRepo(t *testing.T) {
 	is := is.New(t)
 	_, cfg, pkPath := setupServer(t)
-	t.Log("starting server")
-	dst := t.TempDir()
-	t.Cleanup(func() { is.NoErr(os.RemoveAll(dst)) })
 	url := fmt.Sprintf("ssh://localhost:%d/config", cfg.SSH.Port)
-	t.Log("cloning repo")
+	t.Log("cloning repo", url)
 	pk, err := gssh.NewPublicKeysFromFile("git", pkPath, "")
 	is.NoErr(err)
 	pk.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{
@@ -87,40 +83,31 @@ func randomPort() int {
 	return addr.Addr().(*net.TCPAddr).Port
 }
 
-func setupServer(t *testing.T) (*Server, *config.Config, string) {
-	t.Helper()
-	is := is.New(t)
-	pub, pkPath := createKeyPair(t)
-	dp := t.TempDir()
-	is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp))
-	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", authorizedKey(pub)))
-	is.NoErr(os.Setenv("SOFT_SERVE_GIT_ENABLED", "false"))
-	is.NoErr(os.Setenv("SOFT_SERVE_SSH_PORT", strconv.Itoa(randomPort())))
-	// is.NoErr(os.Setenv("SOFT_SERVE_DB_DRIVER", "fake"))
-	t.Cleanup(func() {
-		is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH"))
-		is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_PORT"))
-		is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY"))
-		is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_ENABLED"))
-		// is.NoErr(os.Unsetenv("SOFT_SERVE_DB_DRIVER"))
-		is.NoErr(os.RemoveAll(dp))
-	})
+func setupServer(tb testing.TB) (*Server, *config.Config, string) {
+	tb.Helper()
+	pub, pkPath := createKeyPair(tb)
+	dp := tb.TempDir()
+	tb.Setenv("SOFT_SERVE_DATA_PATH", dp)
+	tb.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", authorizedKey(pub))
+	tb.Setenv("SOFT_SERVE_GIT_ENABLED", "false")
+	tb.Setenv("SOFT_SERVE_SSH_PORT", strconv.Itoa(randomPort()))
+	// tb.Setenv("SOFT_SERVE_DB_DRIVER", "fake")
 	cfg := config.DefaultConfig() //.WithDB(&fakedb.FakeDB{})
 	s := NewServer(cfg)
 	go func() {
-		t.Log("starting server")
+		tb.Log("starting server")
 		s.Start()
 	}()
-	t.Cleanup(func() {
+	tb.Cleanup(func() {
 		s.Close()
 	})
 	return s, cfg, pkPath
 }
 
-func createKeyPair(t *testing.T) (ssh.PublicKey, string) {
-	t.Helper()
-	is := is.New(t)
-	keyDir := t.TempDir()
+func createKeyPair(tb testing.TB) (ssh.PublicKey, string) {
+	tb.Helper()
+	is := is.New(tb)
+	keyDir := tb.TempDir()
 	kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519)
 	is.NoErr(err)
 	pubkey, _, _, _, err := ssh.ParseAuthorizedKey(kp.PublicKey())