diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a05925 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test-infinite/ \ No newline at end of file diff --git a/README.md b/README.md index c3112cd..6619f04 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,17 @@ Start the server: infinite-git ``` -Options: -- `-addr`: HTTP server address (default: ":8080") -- `-repo`: Path to Git repository (default: "./infinite-repo") -- `-log-level`: Log level - debug, info, warn, error (default: "info") +Configuration via environment variables: +- `PORT`: HTTP server port (default: "8080") +- `REPO_PATH`: Path to Git repository (default: "./infinite-repo") Example: ```bash -infinite-git -addr :3000 -repo /tmp/my-infinite-repo -log-level debug +PORT=3000 REPO_PATH=/tmp/my-infinite-repo infinite-git ``` +Logging is configured via the standard clog environment variables. + ## Testing Clone the repository: diff --git a/go.mod b/go.mod index 74d5b8c..505728b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/chainguard-dev/clog v1.7.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect @@ -17,6 +18,7 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sethvargo/go-envconfig v1.3.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.37.0 // indirect diff --git a/go.sum b/go.sum index 7b3daf9..8ba6c86 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/chainguard-dev/clog v1.7.0 h1:guPznsK8vLHvzz1QJe2yU6MFeYaiSOFOQBYw4OXu+g8= +github.com/chainguard-dev/clog v1.7.0/go.mod h1:4+WFhRMsGH79etYXY3plYdp+tCz/KCkU8fAr0HoaPvs= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= @@ -34,6 +36,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= +github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= diff --git a/internal/protocol/upload_pack.go b/internal/protocol/upload_pack.go index 1e84007..3973bd0 100644 --- a/internal/protocol/upload_pack.go +++ b/internal/protocol/upload_pack.go @@ -27,9 +27,8 @@ func (u *UploadPack) HandleRequest(r io.Reader, w io.Writer) error { reader := pktline.NewReader(r) writer := pktline.NewWriter(w) - // Read want/have lines + // Read want lines first var wants []string - var haves []string var capabilities []string for { @@ -38,7 +37,7 @@ func (u *UploadPack) HandleRequest(r io.Reader, w io.Writer) error { break // flush-pkt } if err != nil { - return fmt.Errorf("reading request: %w", err) + return fmt.Errorf("reading wants: %w", err) } if strings.HasPrefix(line, "want ") { @@ -51,19 +50,71 @@ func (u *UploadPack) HandleRequest(r io.Reader, w io.Writer) error { if len(parts) > 1 && len(capabilities) == 0 { capabilities = strings.Split(parts[1], " ") } - } else if strings.HasPrefix(line, "have ") { - haves = append(haves, line[5:]) - } else if line == "done" { - break } } - // For now, we'll send all requested objects without negotiation - // In a real implementation, we'd use the "have" list to optimize + // Now handle negotiation phase + // The client may send: + // 1. "done" immediately (for clone) + // 2. "have" lines followed by flush, then we NAK, then more haves or done - // Send NAK (we don't have any of the client's objects) + for { + // Read lines until we get a flush or done + var haves []string + gotDone := false + + for { + line, err := reader.ReadString() + if err == io.EOF { + // Flush packet - end of this batch + break + } + if err != nil { + return fmt.Errorf("reading negotiation: %w", err) + } + + if line == "done" { + gotDone = true + break + } else if strings.HasPrefix(line, "have ") { + haves = append(haves, line[5:]) + } else if line != "" { + return fmt.Errorf("unexpected line in negotiation: %q", line) + } + } + + // If we got done, we're finished + if gotDone { + break + } + + // If we got haves, send NAK and continue + if len(haves) > 0 { + if err := writer.WriteString("NAK\n"); err != nil { + return fmt.Errorf("writing NAK: %w", err) + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("flushing NAK: %w", err) + } + } else if !gotDone { + // Empty flush without haves or done - client expects NAK + if err := writer.WriteString("NAK\n"); err != nil { + return fmt.Errorf("writing NAK for empty flush: %w", err) + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("flushing NAK: %w", err) + } + } + } + + // Read the flush after "done" + if _, err := reader.ReadString(); err != io.EOF { + return fmt.Errorf("expected flush after done") + } + + // Send final NAK before packfile if err := writer.WriteString("NAK\n"); err != nil { - return fmt.Errorf("writing NAK: %w", err) + return fmt.Errorf("writing final NAK: %w", err) } // Check if client supports side-band diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 551d61a..cfb88d0 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -5,12 +5,14 @@ import ( "net/http" "strings" + "github.com/chainguard-dev/clog" "github.com/imjasonh/infinite-git/internal/pktline" "github.com/imjasonh/infinite-git/internal/protocol" ) // handleInfoRefs handles the reference discovery phase. func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { + log := clog.FromContext(r.Context()) service := r.URL.Query().Get("service") // Only support git-upload-pack (fetch/clone) @@ -25,12 +27,12 @@ func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { s.mu.Unlock() if err != nil { - s.logger.Error("failed to generate commit", "error", err) + log.Error("failed to generate commit", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - s.logger.Info("generated new commit", "sha", commitSHA, "counter", s.generator.GetCounter()) + log.Info("generated new commit", "sha", commitSHA, "counter", s.generator.GetCounter()) // Set headers w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) @@ -41,18 +43,18 @@ func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { // Service declaration if err := pw.Writef("# service=%s\n", service); err != nil { - s.logger.Error("failed to write service line", "error", err) + log.Error("failed to write service line", "error", err) return } if err := pw.Flush(); err != nil { - s.logger.Error("failed to write flush", "error", err) + log.Error("failed to write flush", "error", err) return } // Get current refs refs, err := s.repo.GetRefs() if err != nil { - s.logger.Error("failed to get refs", "error", err) + log.Error("failed to get refs", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -64,13 +66,13 @@ func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { for ref, oid := range refs { if first { if err := pw.Writef("%s %s\x00%s\n", oid, ref, capabilities); err != nil { - s.logger.Error("failed to write ref with capabilities", "error", err) + log.Error("failed to write ref with capabilities", "error", err) return } first = false } else { if err := pw.Writef("%s %s\n", oid, ref); err != nil { - s.logger.Error("failed to write ref", "error", err) + log.Error("failed to write ref", "error", err) return } } @@ -78,13 +80,14 @@ func (s *Server) handleInfoRefs(w http.ResponseWriter, r *http.Request) { // Final flush if err := pw.Flush(); err != nil { - s.logger.Error("failed to write final flush", "error", err) + log.Error("failed to write final flush", "error", err) return } } // handleUploadPack handles the pack upload phase. func (s *Server) handleUploadPack(w http.ResponseWriter, r *http.Request) { + log := clog.FromContext(r.Context()) if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -99,10 +102,10 @@ func (s *Server) handleUploadPack(w http.ResponseWriter, r *http.Request) { // Process the request if err := up.HandleRequest(r.Body, w); err != nil { - s.logger.Error("upload-pack failed", "error", err) + log.Error("upload-pack failed", "error", err) // Don't send HTTP error here as we may have already started writing response return } - s.logger.Info("completed upload-pack") + log.Info("completed upload-pack") } diff --git a/internal/server/server.go b/internal/server/server.go index bd07516..ecf2724 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,10 +1,10 @@ package server import ( - "log/slog" "net/http" "sync" + "github.com/chainguard-dev/clog" "github.com/imjasonh/infinite-git/internal/generator" "github.com/imjasonh/infinite-git/internal/repo" ) @@ -14,15 +14,13 @@ type Server struct { repo *repo.Repository generator *generator.Generator mu sync.Mutex - logger *slog.Logger } // New creates a new Git HTTP server. -func New(r *repo.Repository, logger *slog.Logger) *Server { +func New(r *repo.Repository) *Server { return &Server{ repo: r, generator: generator.New(r), - logger: logger, } } @@ -44,7 +42,8 @@ func (s *Server) Handler() http.Handler { // logMiddleware logs HTTP requests. func (s *Server) logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.logger.Info("request", + log := clog.FromContext(r.Context()) + log.Info("request", "method", r.Method, "path", r.URL.Path, "query", r.URL.RawQuery, @@ -56,7 +55,8 @@ func (s *Server) logMiddleware(next http.Handler) http.Handler { // handleReceivePack rejects push operations. func (s *Server) handleReceivePack(w http.ResponseWriter, r *http.Request) { - s.logger.Info("rejecting push attempt", "path", r.URL.Path) + log := clog.FromContext(r.Context()) + log.Info("rejecting push attempt", "path", r.URL.Path) http.Error(w, "Push access denied", http.StatusForbidden) } diff --git a/main.go b/main.go index 9c2d0f2..1f5de6b 100644 --- a/main.go +++ b/main.go @@ -2,85 +2,48 @@ package main import ( "context" - "flag" "log/slog" "net/http" "os" - "os/signal" - "syscall" "time" + _ "github.com/chainguard-dev/clog/gcp/init" "github.com/imjasonh/infinite-git/internal/repo" "github.com/imjasonh/infinite-git/internal/server" + "github.com/sethvargo/go-envconfig" ) +var env = envconfig.MustProcess(context.Background(), &struct { + Port string `env:"PORT,default=8080"` + RepoPath string `env:"REPO_PATH,default=./infinite-repo"` +}{}) + func main() { - var ( - addr = flag.String("addr", ":8080", "HTTP server address") - repoPath = flag.String("repo", "./infinite-repo", "Path to Git repository") - logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)") - ) - flag.Parse() - - // Set up logger - level := slog.LevelInfo - switch *logLevel { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: level, - })) - slog.SetDefault(logger) + // clog/gcp/init automatically sets up the logger // Initialize repository - logger.Info("initializing repository", "path", *repoPath) - gitRepo, err := repo.New(*repoPath) + slog.Info("initializing repository", "env", env) + gitRepo, err := repo.New(env.RepoPath) if err != nil { - logger.Error("failed to initialize repository", "error", err) + slog.Error("failed to initialize repository", "error", err) os.Exit(1) } // Create server - srv := server.New(gitRepo, logger) + srv := server.New(gitRepo) // Set up HTTP server httpServer := &http.Server{ - Addr: *addr, + Addr: ":" + env.Port, Handler: srv.Handler(), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } - // Start server in goroutine - go func() { - logger.Info("starting HTTP server", "addr", *addr) - logger.Info("to clone: git clone http://localhost" + *addr) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("HTTP server error", "error", err) - os.Exit(1) - } - }() - - // Wait for interrupt signal - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - <-sigChan - - // Graceful shutdown - logger.Info("shutting down server...") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := httpServer.Shutdown(ctx); err != nil { - logger.Error("server shutdown error", "error", err) + slog.Info("starting HTTP server", "port", env.Port) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server error", "error", err) + os.Exit(1) } - - logger.Info("server stopped") } diff --git a/main_test.go b/main_test.go index 9ad4a4d..bed2de8 100644 --- a/main_test.go +++ b/main_test.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "io" - "log/slog" "net/http/httptest" "os" "path/filepath" @@ -29,9 +27,7 @@ func TestCloneAndPull(t *testing.T) { t.Fatalf("failed to create server repo: %v", err) } - // Create server with silent logger - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := server.New(serverRepo, logger) + srv := server.New(serverRepo) // Start test server ts := httptest.NewServer(srv.Handler()) @@ -120,9 +116,7 @@ func TestConcurrentPulls(t *testing.T) { t.Fatalf("failed to create server repo: %v", err) } - // Create server with silent logger - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := server.New(serverRepo, logger) + srv := server.New(serverRepo) // Start test server ts := httptest.NewServer(srv.Handler()) @@ -207,9 +201,7 @@ func TestPushRejection(t *testing.T) { t.Fatalf("failed to create server repo: %v", err) } - // Create server with silent logger - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := server.New(serverRepo, logger) + srv := server.New(serverRepo) // Start test server ts := httptest.NewServer(srv.Handler()) diff --git a/test.sh b/test.sh index 87df4ec..d3c07a4 100755 --- a/test.sh +++ b/test.sh @@ -1,5 +1,8 @@ #!/bin/bash -set -e +set -ex + +# Enable Git packet tracing for debugging +export GIT_TRACE_PACKET=1 # Cleanup function cleanup() { @@ -12,8 +15,11 @@ cleanup() { # Trap EXIT to ensure cleanup happens trap cleanup EXIT +# Kill any existing server in case it was not cleaned up +lsof -ti:9876 | xargs kill -9 2>/dev/null || true + echo "Starting infinite-git server..." -go run . -addr :9876 -repo /tmp/test-infinite-git & +PORT=9876 REPO_PATH=/tmp/test-infinite-git go run . & SERVER_PID=$! # Give server time to start @@ -43,10 +49,6 @@ echo "Files after second pull:" ls -la echo "Checking commits..." -git log --oneline +git --no-pager log --oneline -# Test directory cleanup -cd / -rm -rf "$TEST_DIR" - -echo "Test complete!" \ No newline at end of file +echo "Test complete!"