func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) == 0 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}

+  if len(modulePath) < 2 || !validateSegments(modulePath) {


Edit

	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}


The file /Users/steve.briggs/projects/operations/services/crow/internal/registry/handler.go has been updated successfully.

	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}

+  if len(segments) < 2 || !validateSegments(segments) {


	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}


	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}


Now update the tests — add the missing edge case tests and update the `ListVersions` single-segment case (which now fails validation since `< 2`), plus add segment validation tests:


		{
			name:           "returns versions for single-segment path",
			modulePath:     []string{"mymodule"},
			versions:       []string{"1.0.0"},
			expectedStatus: http.StatusOK,
			expectedBody:   `{"modules":[{"versions":[{"version":"1.0.0"}]}]}`,
		},
		{
			name:           "returns 404 when no versions found",

		{
			name:           "returns 400 for single-segment path (below minimum)",
			modulePath:     []string{"mymodule"},
			versions:       nil,
			expectedStatus: http.StatusBadRequest,
		},
		{
			name:           "returns 404 when no versions found",


The file /Users/steve.briggs/projects/operations/services/crow/internal/registry/handler_test.go has been updated successfully.

		{
			name:           "returns versions for single-segment path",
			modulePath:     []string{"mymodule"},
			versions:       []string{"1.0.0"},
			expectedStatus: http.StatusOK,
			expectedBody:   `{"modules":[{"versions":[{"version":"1.0.0"}]}]}`,
		},
		{
			name:           "returns 404 when no versions found",

		{
			name:           "returns 400 for single-segment path (below minimum)",
			modulePath:     []string{"mymodule"},
			versions:       nil,
			expectedStatus: http.StatusBadRequest,
		},
		{
			name:           "returns 404 when no versions found",

expectedBody:   `{"modules":[{"versions":[{"version":"2.0.0"}]}]}`,

-      name:           "returns versions for single-segment path",

+      name:           "returns 400 for single-segment path (below minimum)",

-      versions:       []string{"1.0.0"},

-      expectedBody:   `{"modules":[{"versions":[{"version":"1.0.0"}]}]}`,

+      expectedStatus: http.StatusBadRequest,


The single-segment mock call would now never be reached (handler returns 400 before calling the store), so I need to make sure testify doesn't complain. Let me also fix the test to not set up a mock expectation for that case:


85			{
86				name:           "returns 500 on store error",
87				modulePath:     []string{"hashicorp", "consul", "aws"},
88				storeErr:       assert.AnError,
89				expectedStatus: http.StatusInternalServerError,
90			},
91		}
92
93		for _, tt := range tests {
94			t.Run(tt.name, func(t *testing.T) {
95				e, mockStore := setupTest(t)
96
97				mockStore.On("ListVersions", mock.Anything, tt.modulePath).
98					Return(tt.versions, tt.storeErr)
99
100				path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"
101				req := httptest.NewRequest(http.MethodGet, path, nil)
102				rec := httptest.NewRecorder()
103				e.ServeHTTP(rec, req)
104
105				assert.Equal(t, tt.expectedStatus, rec.Code)
106				if tt.expectedBody != "" {
107					assert.JSONEq(t, tt.expectedBody, rec.Body.String())
108				}
109			})
110		}
111	}
112
113	func TestDownload(t *testing.T) {
114		tests := []struct {

		{
			name:           "returns 500 on store error",
			modulePath:     []string{"hashicorp", "consul", "aws"},
			storeErr:       assert.AnError,
			expectedStatus: http.StatusInternalServerError,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)

			mockStore.On("ListVersions", mock.Anything, tt.modulePath).
				Return(tt.versions, tt.storeErr)

			path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"
			req := httptest.NewRequest(http.MethodGet, path, nil)
			rec := httptest.NewRecorder()
			e.ServeHTTP(rec, req)

			assert.Equal(t, tt.expectedStatus, rec.Code)
			if tt.expectedBody != "" {
				assert.JSONEq(t, tt.expectedBody, rec.Body.String())
			}
		})
	}
}

func TestDownload(t *testing.T) {
	tests := []struct {


I need to conditionally set up the mock expectation only when the handler will actually reach the store (i.e., when the path is valid):


	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)

			mockStore.On("ListVersions", mock.Anything, tt.modulePath).
				Return(tt.versions, tt.storeErr)

			path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)

			if len(tt.modulePath) >= 2 {
				mockStore.On("ListVersions", mock.Anything, tt.modulePath).
					Return(tt.versions, tt.storeErr)
			}

			path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"


	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)

			mockStore.On("ListVersions", mock.Anything, tt.modulePath).
				Return(tt.versions, tt.storeErr)

			path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)

			if len(tt.modulePath) >= 2 {
				mockStore.On("ListVersions", mock.Anything, tt.modulePath).
					Return(tt.versions, tt.storeErr)
			}

			path := "/v1/modules/" + strings.Join(tt.modulePath, "/") + "/versions"

-      mockStore.On("ListVersions", mock.Anything, tt.modulePath).

-        Return(tt.versions, tt.storeErr)

+        mockStore.On("ListVersions", mock.Anything, tt.modulePath).

+          Return(tt.versions, tt.storeErr)

req := httptest.NewRequest(http.MethodGet, path, nil)


Now add the edge case tests hariscodes requested, plus segment validation tests. I'll add them after `TestParsePathSegments`:


func TestModuleGETInvalidPaths(t *testing.T) {
	tests := []struct {
		name           string
		path           string
		expectedStatus int
		expectedBody   string
	}{
		{
			// Wildcard captures "versions" (no leading /versions suffix) → not dispatched.
			name:           "GET /v1/modules//versions returns 404",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},
		{
			// Neither /versions nor /download suffix.
			name:           "GET /v1/modules/foo/bar/aws returns 404",
			path:           "/v1/modules/foo/bar/aws",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, _ := setupTest(t)
			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
			rec := httptest.NewRecorder()
			e.ServeHTTP(rec, req)
			assert.Equal(t, tt.expectedStatus, rec.Code)
			if tt.expectedBody != "" {
				assert.JSONEq(t, tt.expectedBody, rec.Body.String())
			}
		})
	}
}

func TestUploadInvalidPaths(t *testing.T) {
	tests := []struct {
		name           string
		path           string
		expectedStatus int
	}{
		{
			// Only one segment: no module path before version.
			name:           "PUT /v1/modules/1.0.0 returns 400",
			path:           "/v1/modules/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Segment with path traversal characters.
			name:           "PUT with ../traversal segment returns 400",
			path:           "/v1/modules/foo/../bar/aws/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Segment with special characters.
			name:           "PUT with special characters in segment returns 400",
			path:           "/v1/modules/foo/b@r/aws/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)
			mockStore.On("HeadModule", mock.Anything, mock.Anything, mock.Anything).
				Return(false, nil).Maybe()
			body := validGzipBody(t)
			req := httptest.NewRequest(http.MethodPut, tt.path, body)
			req.Header.Set("Content-Type", "application/octet-stream")
			rec := httptest.NewRecorder()
			e.ServeHTTP(rec, req)
			assert.Equal(t, tt.expectedStatus, rec.Code)
		})
	}
}

func TestValidateSegments(t *testing.T) {
	tests := []struct {
		segments []string
		valid    bool
	}{
		{[]string{"hashicorp", "consul", "aws"}, true},
		{[]string{"my-org", "my_module", "1.0.0"}, true},
		{[]string{"foo", "b@r", "aws"}, false},
		{[]string{"foo", "../traversal", "aws"}, false},
		{[]string{"foo", "", "aws"}, false},
		{[]string{"-startshyphen", "aws"}, false},
	}
	for _, tt := range tests {
		t.Run(strings.Join(tt.segments, "/"), func(t *testing.T) {
			assert.Equal(t, tt.valid, validateSegments(tt.segments))
		})
	}
}

func TestLivez(t *testing.T) {


func TestModuleGETInvalidPaths(t *testing.T) {
	tests := []struct {
		name           string
		path           string
		expectedStatus int
		expectedBody   string
	}{
		{
			// Wildcard captures "versions" (no leading /versions suffix) → not dispatched.
			name:           "GET /v1/modules//versions returns 404",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},
		{
			// Neither /versions nor /download suffix.
			name:           "GET /v1/modules/foo/bar/aws returns 404",
			path:           "/v1/modules/foo/bar/aws",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, _ := setupTest(t)
			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
			rec := httptest.NewRecorder()
			e.ServeHTTP(rec, req)
			assert.Equal(t, tt.expectedStatus, rec.Code)
			if tt.expectedBody != "" {
				assert.JSONEq(t, tt.expectedBody, rec.Body.String())
			}
		})
	}
}

func TestUploadInvalidPaths(t *testing.T) {
	tests := []struct {
		name           string
		path           string
		expectedStatus int
	}{
		{
			// Only one segment: no module path before version.
			name:           "PUT /v1/modules/1.0.0 returns 400",
			path:           "/v1/modules/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Segment with path traversal characters.
			name:           "PUT with ../traversal segment returns 400",
			path:           "/v1/modules/foo/../bar/aws/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Segment with special characters.
			name:           "PUT with special characters in segment returns 400",
			path:           "/v1/modules/foo/b@r/aws/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			e, mockStore := setupTest(t)
			mockStore.On("HeadModule", mock.Anything, mock.Anything, mock.Anything).
				Return(false, nil).Maybe()
			body := validGzipBody(t)
			req := httptest.NewRequest(http.MethodPut, tt.path, body)
			req.Header.Set("Content-Type", "application/octet-stream")
			rec := httptest.NewRecorder()
			e.ServeHTTP(rec, req)
			assert.Equal(t, tt.expectedStatus, rec.Code)
		})
	}
}

func TestValidateSegments(t *testing.T) {
	tests := []struct {
		segments []string
		valid    bool
	}{
		{[]string{"hashicorp", "consul", "aws"}, true},
		{[]string{"my-org", "my_module", "1.0.0"}, true},
		{[]string{"foo", "b@r", "aws"}, false},
		{[]string{"foo", "../traversal", "aws"}, false},
		{[]string{"foo", "", "aws"}, false},
		{[]string{"-startshyphen", "aws"}, false},
	}
	for _, tt := range tests {
		t.Run(strings.Join(tt.segments, "/"), func(t *testing.T) {
			assert.Equal(t, tt.valid, validateSegments(tt.segments))
		})
	}
}

func TestLivez(t *testing.T) {

+func TestModuleGETInvalidPaths(t *testing.T) {

+      // Wildcard captures "versions" (no leading /versions suffix) → not dispatched.

+      name:           "GET /v1/modules//versions returns 404",

+      path:           "/v1/modules//versions",

+      expectedStatus: http.StatusNotFound,

+      expectedBody:   `{"error":"path must end in /versions or /download"}`,

+      // Neither /versions nor /download suffix.

+      name:           "GET /v1/modules/foo/bar/aws returns 404",

+      path:           "/v1/modules/foo/bar/aws",

+    t.Run(tt.name, func(t *testing.T) {

+      req := httptest.NewRequest(http.MethodGet, tt.path, nil)

+      assert.Equal(t, tt.expectedStatus, rec.Code)

+        assert.JSONEq(t, tt.expectedBody, rec.Body.String())

+func TestUploadInvalidPaths(t *testing.T) {

+      // Only one segment: no module path before version.

+      name:           "PUT /v1/modules/1.0.0 returns 400",

+      expectedStatus: http.StatusBadRequest,

+      // Segment with path traversal characters.

+      name:           "PUT with ../traversal segment returns 400",

+      path:           "/v1/modules/foo/../bar/aws/1.0.0",

+      // Segment with special characters.

+      name:           "PUT with special characters in segment returns 400",

+      path:           "/v1/modules/foo/b@r/aws/1.0.0",

+      mockStore.On("HeadModule", mock.Anything, mock.Anything, mock.Anything).

+      req := httptest.NewRequest(http.MethodPut, tt.path, body)

+      req.Header.Set("Content-Type", "application/octet-stream")

+func TestValidateSegments(t *testing.T) {

+    {[]string{"hashicorp", "consul", "aws"}, true},

+    {[]string{"foo", "b@r", "aws"}, false},

+    {[]string{"foo", "../traversal", "aws"}, false},

+    {[]string{"foo", "", "aws"}, false},

+    {[]string{"-startshyphen", "aws"}, false},

+    t.Run(strings.Join(tt.segments, "/"), func(t *testing.T) {

+      assert.Equal(t, tt.valid, validateSegments(tt.segments))


Also update `s3_test.go` to cover the new `extractVersion` guard against `/` in version strings:


		{"hashicorp/consul/aws/readme.md", "hashicorp/consul/aws/", "", false},
		{"hashicorp/consul/aws/", "hashicorp/consul/aws/", "", false},

		{"hashicorp/consul/aws/readme.md", "hashicorp/consul/aws/", "", false},
		{"hashicorp/consul/aws/", "hashicorp/consul/aws/", "", false},
		// A deeper nested path must NOT bleed into a shallower module's version list.
		{"org/mod/sub/2.0.0.tar.gz", "org/mod/", "", false},


The file /Users/steve.briggs/projects/operations/services/crow/internal/storage/s3_test.go has been updated successfully.

		{"hashicorp/consul/aws/readme.md", "hashicorp/consul/aws/", "", false},
		{"hashicorp/consul/aws/", "hashicorp/consul/aws/", "", false},
		// A deeper nested path must NOT bleed into a shallower module's version list.
		{"org/mod/sub/2.0.0.tar.gz", "org/mod/", "", false},

package storage

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestObjectKey(t *testing.T) {
	tests := []struct {
		modulePath []string
		version    string
		expected   string
	}{
		{[]string{"hashicorp", "consul", "aws"}, "1.0.0", "hashicorp/consul/aws/1.0.0.tar.gz"},
		{[]string{"myorg", "team", "infra", "consul", "aws"}, "2.3.1", "myorg/team/infra/consul/aws/2.3.1.tar.gz"},
		{[]string{"single"}, "1.0.0", "single/1.0.0.tar.gz"},
	}
	for _, tt := range tests {
		t.Run(tt.expected, func(t *testing.T) {
			assert.Equal(t, tt.expected, objectKey(tt.modulePath, tt.version))
		})
	}
}

func TestPrefix(t *testing.T) {
	tests := []struct {
		modulePath []string
		expected   string
	}{
		{[]string{"hashicorp", "consul", "aws"}, "hashicorp/consul/aws/"},
		{[]string{"myorg", "team", "infra", "consul", "aws"}, "myorg/team/infra/consul/aws/"},
		{[]string{"single"}, "single/"},
	}
	for _, tt := range tests {
		t.Run(tt.expected, func(t *testing.T) {
			assert.Equal(t, tt.expected, prefix(tt.modulePath))
		})
	}
}

func TestExtractVersion(t *testing.T) {
	tests := []struct {
		key      string
		prefix   string
		expected string
		valid    bool
	}{
		{"hashicorp/consul/aws/1.0.0.tar.gz", "hashicorp/consul/aws/", "1.0.0", true},
		{"hashicorp/consul/aws/2.3.1.tar.gz", "hashicorp/consul/aws/", "2.3.1", true},
		{"myorg/team/infra/consul/aws/2.0.0.tar.gz", "myorg/team/infra/consul/aws/", "2.0.0", true},
		{"hashicorp/consul/aws/readme.md", "hashicorp/consul/aws/", "", false},
		{"hashicorp/consul/aws/", "hashicorp/consul/aws/", "", false},
	}

	for _, tt := range tests {
		t.Run(tt.key, func(t *testing.T) {
			v, ok := extractVersion(tt.key, tt.prefix)
			assert.Equal(t, tt.valid, ok)
			if ok {
				assert.Equal(t, tt.expected, v)
			}
		})
	}
}

+    // A deeper nested path must NOT bleed into a shallower module's version list.

+    {"org/mod/sub/2.0.0.tar.gz", "org/mod/", "", false},


Bash

Run all tests


Error: Exit code 1
?   	github.com/immuta/operations/services/crow	[no test files]
ok  	github.com/immuta/operations/services/crow/internal/config	(cached)
{"time":"2026-04-16T14:49:07.378519-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"127","message":"failed to list versions: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.378859-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"165","message":"failed to check module existence: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.378931-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"175","message":"failed to generate presigned URL: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.379979-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"203","message":"failed to check module existence: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.380224-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"234","message":"failed to store module: assert.AnError general error for testing"}
--- FAIL: TestModuleGETInvalidPaths (0.00s)
    --- FAIL: TestModuleGETInvalidPaths/GET_/v1/modules//versions_returns_404 (0.00s)
        handler_test.go:356:
            	Error Trace:	/Users/steve.briggs/projects/operations/services/crow/internal/registry/handler_test.go:356
            	Error:      	Not equal:
            	            	expected: 404
            	            	actual  : 400
            	Test:       	TestModuleGETInvalidPaths/GET_/v1/modules//versions_returns_404
        handler_test.go:358:
            	Error Trace:	/Users/steve.briggs/projects/operations/services/crow/internal/registry/handler_test.go:358
            	Error:      	Not equal:
            	            	expected: map[string]interface {}{"error":"path must end in /versions or /download"}
            	            	actual  : map[string]interface {}{"error":"invalid module path"}

            	            	Diff:
            	            	--- Expected
            	            	+++ Actual
            	            	@@ -1,3 +1,3 @@
            	            	 (map[string]interface {}) (len=1) {
            	            	- (string) (len=5) "error": (string) (len=39) "path must end in /versions or /download"
            	            	+ (string) (len=5) "error": (string) (len=19) "invalid module path"
            	            	 }
            	Test:       	TestModuleGETInvalidPaths/GET_/v1/modules//versions_returns_404
{"time":"2026-04-16T14:49:07.381322-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"91","message":"readyz: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.381383-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"97","message":"readyz: assert.AnError general error for testing"}
{"time":"2026-04-16T14:49:07.381518-04:00","level":"ERROR","prefix":"echo","file":"handler.go","line":"97","message":"readyz: assert.AnError general error for testing"}
FAIL
FAIL	github.com/immuta/operations/services/crow/internal/registry	0.654s
ok  	github.com/immuta/operations/services/crow/internal/storage	0.765s
FAIL


The double-slash path gets dispatched to `ListVersions` (wildcard captures `/versions` which has the suffix), then correctly rejected as an empty module path with a 400. That's actually better behavior than 404 — it's a bad request, not a missing resource. I'll update the test expectation to match:


Edit

		{
			// Wildcard captures "versions" (no leading /versions suffix) → not dispatched.
			name:           "GET /v1/modules//versions returns 404",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},

		{
			// Wildcard captures "/versions"; dispatched to ListVersions with an empty
			// module path, which is rejected as a bad request.
			name:           "GET /v1/modules//versions returns 400",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusBadRequest,
			expectedBody:   `{"error":"invalid module path"}`,
		},


		{
			// Wildcard captures "versions" (no leading /versions suffix) → not dispatched.
			name:           "GET /v1/modules//versions returns 404",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusNotFound,
			expectedBody:   `{"error":"path must end in /versions or /download"}`,
		},

		{
			// Wildcard captures "/versions"; dispatched to ListVersions with an empty
			// module path, which is rejected as a bad request.
			name:           "GET /v1/modules//versions returns 400",
			path:           "/v1/modules//versions",
			expectedStatus: http.StatusBadRequest,
			expectedBody:   `{"error":"invalid module path"}`,
		},

-      // Wildcard captures "versions" (no leading /versions suffix) → not dispatched.

-      name:           "GET /v1/modules//versions returns 404",

+      // Wildcard captures "/versions"; dispatched to ListVersions with an empty

+      // module path, which is rejected as a bad request.

+      name:           "GET /v1/modules//versions returns 400",

path:           "/v1/modules//versions",

-      expectedStatus: http.StatusNotFound,

-      expectedBody:   `{"error":"path must end in /versions or /download"}`,

+      expectedStatus: http.StatusBadRequest,

+      expectedBody:   `{"error":"invalid module path"}`,

// Neither /versions nor /download suffix.


Bash

Run all tests


?   	github.com/immuta/operations/services/crow	[no test files]
ok  	github.com/immuta/operations/services/crow/internal/config	(cached)
ok  	github.com/immuta/operations/services/crow/internal/registry	1.108s
ok  	github.com/immuta/operations/services/crow/internal/storage	(cached)


All passing. Here's a summary of what was addressed from the PR:

**Cursor Bugbot — version leak from nested paths (`s3.go`)**
- Added `Delimiter: "/"` to `ListObjectsV2Input` so S3 never returns objects deeper than one level below the prefix
- Added `strings.Contains(version, "/")` guard in `extractVersion` as a second line of defense

**hariscodes — input validation on path segments (`handler.go`)**
- Added `validSegment` regexp (`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) and `validateSegments()` function
- All three handlers now reject requests with invalid characters before touching the store

**hariscodes — `ListVersions` minimum path check**
- Changed `len(modulePath) == 0` → `len(modulePath) < 2` to match the `< 2` pattern used in `Download`/`Upload`

**hariscodes — `moduleGET` error message**
- Changed `"not found"` → `"path must end in /versions or /download"`

**hariscodes — missing edge case tests**
- `GET /v1/modules//versions` → 400 (empty module path after dispatch)
- `PUT /v1/modules/1.0.0` → 400 (only one segment, no module path before version)
- Added `TestValidateSegments` and `TestUploadInvalidPaths` for traversal/special-char cases


Updated Crow to accept variable-length Terraform module paths and addressed all 5 PR review comments from immuta/operations#209. Tests pass; ready to commit and push. (disable recaps in /config)


<command-name>/usage</command-name>
            <command-message>usage</command-message>
            <command-args></command-args>


<local-command-stdout>Status dialog dismissed</local-command-stdout>


<command-name>/stats</command-name>
            <command-message>stats</command-message>
            <command-args></command-args>


<local-command-stdout>Stats dialog dismissed</local-command-stdout>


<command-name>/model</command-name>
            <command-message>model</command-message>
            <command-args></command-args>


<local-command-stdout>Kept model as Sonnet 4.6 (default)</local-command-stdout>


bugbot replied with the following, can you check into this? Inconsistent minimum segment check in ListVersions handler

High Severity

ListVersions checks len(modulePath) < 2, requiring at least 2 module path segments. But Download and Upload check len(segments) < 2 where segments includes the version — effectively requiring only 1 module path segment. This means a module uploaded with a single-segment path (e.g. PUT /v1/modules/mymodule/1.0.0) can be stored and downloaded, but GET /v1/modules/mymodule/versions returns 400. The code's own doc comment also says "at least one segment" contradicting the < 2 check.
Additional Locations (2)

    services/crow/internal/registry/handler.go#L154-L155
    services/crow/internal/registry/handler.go#L192-L193


The bugbot is right. `ListVersions` requires `len(modulePath) < 2` (≥2 segments), but `Download`/`Upload` check `len(segments) < 2` where `segments` includes the version — so they only require ≥1 module path segment. A module stored at `mymodule/1.0.0` can be uploaded and downloaded but `GET /v1/modules/mymodule/versions` returns 400.

The fix: change `Download` and `Upload` from `len(segments) < 2` to `len(segments) < 3` (≥2 module path segments + 1 version), matching `ListVersions`. Also fix the doc comment that still says "at least one segment".


140		resp := map[string]any{
141			"modules": []map[string]any{
142				{"versions": entries},
143			},
144		}
145
146		return c.JSON(http.StatusOK, resp)
147	}
148
149	// Download handles GET /v1/modules/{modulePath...}/{version}/download.
150	// The path must have at least two segments so that modulePath has at least
151	// one element and version is non-empty.
152	func (h *Handler) Download(c echo.Context) error {
153		raw := strings.TrimSuffix(c.Param("*"), "/download")
154		segments := parsePathSegments(raw)
155		if len(segments) < 2 || !validateSegments(segments) {
156			return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
157		}
158		modulePath := segments[:len(segments)-1]
159		version := segments[len(segments)-1]
160
161		ctx := c.Request().Context()
162
163		exists, err := h.store.HeadModule(ctx, modulePath, version)
164		if err != nil {
165			c.Logger().Errorf("failed to check module existence: %v", err)
166			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
167		}
168
169		if !exists {
170			return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
171		}
172
173		url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
174		if err != nil {
175			c.Logger().Errorf("failed to generate presigned URL: %v", err)
176			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
177		}
178
179		c.Response().Header().Set("X-Terraform-Get", url)
180		return c.NoContent(http.StatusNoContent)
181	}
182
183	// Upload handles PUT /v1/modules/{modulePath...}/{version}.
184	// The path must have at least two segments so that modulePath has at least
185	// one element and version is non-empty.
186	//
187	// The existence check and write are not atomic (TOCTOU): a concurrent upload
188	// between HeadModule and PutModule could result in a silent overwrite because
189	// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
190	// universally supported across S3-compatible backends, so we accept this trade-off.
191	func (h *Handler) Upload(c echo.Context) error {
192		segments := parsePathSegments(c.Param("*"))
193		if len(segments) < 2 || !validateSegments(segments) {
194			return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
195		}
196		modulePath := segments[:len(segments)-1]
197		version := segments[len(segments)-1]
198
199		ctx := c.Request().Context()
200
201		exists, err := h.store.HeadModule(ctx, modulePath, version)
202		if err != nil {
203			c.Logger().Errorf("failed to check module existence: %v", err)
204			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
205		}
206		if exists {
207			return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
208		}
209

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}


112	type versionEntry struct {
113		Version string `json:"version"`
114	}
115
116	// ListVersions handles GET /v1/modules/{modulePath...}/versions.
117	// modulePath must have at least one segment (e.g. namespace/name/provider).
118	func (h *Handler) ListVersions(c echo.Context) error {
119		raw := strings.TrimSuffix(c.Param("*"), "/versions")
120		modulePath := parsePathSegments(raw)
121		if len(modulePath) < 2 || !validateSegments(modulePath) {
122			return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
123		}
124
125		versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
126		if err != nil {
127			c.Logger().Errorf("failed to list versions: %v", err)
128			return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
129		}
130
131		if len(versions) == 0 {

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {


Edit

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least two segments (e.g. namespace/name/provider).


The file /Users/steve.briggs/projects/operations/services/crow/internal/registry/handler.go has been updated successfully.

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least two segments (e.g. namespace/name/provider).

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least one segment (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}

-// modulePath must have at least one segment (e.g. namespace/name/provider).

+// modulePath must have at least two segments (e.g. namespace/name/provider).


bugbot replied with the following, can you check into this? Inconsistent minimum segment check in ListVersions handler  High Severity  ListVersions checks len(modulePath) < 2, requiring at least 2 mod…


// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least three segments so that modulePath has at least
// two elements and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 3 || !validateSegments(segments) {


// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least three segments so that modulePath has at least
// two elements and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 3 || !validateSegments(segments) {

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least two segments (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}

-// The path must have at least two segments so that modulePath has at least

-// one element and version is non-empty.

+// The path must have at least three segments so that modulePath has at least

+// two elements and version is non-empty.

-  if len(segments) < 2 || !validateSegments(segments) {

+  if len(segments) < 3 || !validateSegments(segments) {


// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least three segments so that modulePath has at least
// two elements and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 3 || !validateSegments(segments) {


// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least three segments so that modulePath has at least
// two elements and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 3 || !validateSegments(segments) {

package registry

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/immuta/operations/services/crow/internal/storage"
	"github.com/labstack/echo/v4"
)

// validSegment matches a single path segment: starts with alphanumeric,
// followed by alphanumeric, dots, underscores, or hyphens.
var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// validateSegments returns false if any segment contains characters outside
// the allowed set, guarding against path traversal and injection.
func validateSegments(segments []string) bool {
	for _, s := range segments {
		if !validSegment.MatchString(s) {
			return false
		}
	}
	return true
}

// gzipMagic is the two-byte header for gzip files.
var gzipMagic = []byte{0x1f, 0x8b}

type Handler struct {
	store         storage.Client
	presignExpiry time.Duration
	maxUploadSize int64
	rwPassed      atomic.Bool
}

func NewHandler(store storage.Client, presignExpiry time.Duration, maxUploadSize int64) *Handler {
	return &Handler{store: store, presignExpiry: presignExpiry, maxUploadSize: maxUploadSize}
}

func (h *Handler) Register(e *echo.Echo) {
	e.GET("/.well-known/terraform.json", h.ServiceDiscovery)
	e.GET("/v1/modules/*", h.moduleGET)
	e.PUT("/v1/modules/*", h.Upload)
	e.GET("/health", h.Health)
	e.GET("/livez", h.Livez)
	e.GET("/readyz", h.Readyz)
}

// moduleGET dispatches GET requests under /v1/modules/ based on the path suffix.
func (h *Handler) moduleGET(c echo.Context) error {
	path := c.Param("*")
	if strings.HasSuffix(path, "/versions") {
		return h.ListVersions(c)
	}
	if strings.HasSuffix(path, "/download") {
		return h.Download(c)
	}
	return c.JSON(http.StatusNotFound, map[string]string{"error": "path must end in /versions or /download"})
}

// parsePathSegments splits a URL path string into non-empty segments.
func parsePathSegments(path string) []string {
	parts := strings.Split(path, "/")
	segments := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			segments = append(segments, p)
		}
	}
	return segments
}

func (*Handler) Health(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (*Handler) Livez(c echo.Context) error {
	return c.NoContent(http.StatusOK)
}

func (h *Handler) Readyz(c echo.Context) error {
	ctx := c.Request().Context()

	if err := h.store.HeadBucket(ctx); err != nil {
		c.Logger().Errorf("readyz: %v", err)
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "head bucket check failed"})
	}

	if !h.rwPassed.Load() {
		if err := h.store.CheckReadWrite(ctx); err != nil {
			c.Logger().Errorf("readyz: %v", err)
			return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "read/write check failed"})
		}
		h.rwPassed.Store(true)
	}

	return c.NoContent(http.StatusOK)
}

func (*Handler) ServiceDiscovery(c echo.Context) error {
	return c.JSON(http.StatusOK, map[string]string{
		"modules.v1": "/v1/modules/",
	})
}

type versionEntry struct {
	Version string `json:"version"`
}

// ListVersions handles GET /v1/modules/{modulePath...}/versions.
// modulePath must have at least two segments (e.g. namespace/name/provider).
func (h *Handler) ListVersions(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/versions")
	modulePath := parsePathSegments(raw)
	if len(modulePath) < 2 || !validateSegments(modulePath) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}

	versions, err := h.store.ListVersions(c.Request().Context(), modulePath)
	if err != nil {
		c.Logger().Errorf("failed to list versions: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if len(versions) == 0 {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module not found"})
	}

	entries := make([]versionEntry, len(versions))
	for i, v := range versions {
		entries[i] = versionEntry{Version: v}
	}

	resp := map[string]any{
		"modules": []map[string]any{
			{"versions": entries},
		},
	}

	return c.JSON(http.StatusOK, resp)
}

// Download handles GET /v1/modules/{modulePath...}/{version}/download.
// The path must have at least three segments so that modulePath has at least
// two elements and version is non-empty.
func (h *Handler) Download(c echo.Context) error {
	raw := strings.TrimSuffix(c.Param("*"), "/download")
	segments := parsePathSegments(raw)
	if len(segments) < 3 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	if !exists {
		return c.JSON(http.StatusNotFound, map[string]string{"error": "module version not found"})
	}

	url, err := h.store.PresignGetModule(ctx, modulePath, version, h.presignExpiry)
	if err != nil {
		c.Logger().Errorf("failed to generate presigned URL: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	c.Response().Header().Set("X-Terraform-Get", url)
	return c.NoContent(http.StatusNoContent)
}

// Upload handles PUT /v1/modules/{modulePath...}/{version}.
// The path must have at least two segments so that modulePath has at least
// one element and version is non-empty.
//
// The existence check and write are not atomic (TOCTOU): a concurrent upload
// between HeadModule and PutModule could result in a silent overwrite because
// S3 PutObject is last-write-wins. Conditional writes (If-None-Match) are not
// universally supported across S3-compatible backends, so we accept this trade-off.
func (h *Handler) Upload(c echo.Context) error {
	segments := parsePathSegments(c.Param("*"))
	if len(segments) < 2 || !validateSegments(segments) {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid module path"})
	}
	modulePath := segments[:len(segments)-1]
	version := segments[len(segments)-1]

	ctx := c.Request().Context()

	exists, err := h.store.HeadModule(ctx, modulePath, version)
	if err != nil {
		c.Logger().Errorf("failed to check module existence: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}
	if exists {
		return c.JSON(http.StatusConflict, map[string]string{"error": "module version already exists"})
	}

	body := http.MaxBytesReader(c.Response(), c.Request().Body, h.maxUploadSize)
	defer body.Close()

	header := make([]byte, len(gzipMagic))
	if _, err := io.ReadFull(body, header); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is too small or empty"})
	}
	if header[0] != gzipMagic[0] || header[1] != gzipMagic[1] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body is not a valid gzip archive"})
	}

	// Buffer the full body so S3 PutObject gets a seekable reader, which the
	// AWS SDK requires for checksum computation over non-TLS connections.
	// This is bounded by MaxBytesReader so memory usage is capped at maxUploadSize.
	rest, err := io.ReadAll(body)
	if err != nil {
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			return c.JSON(http.StatusBadRequest, map[string]string{"error": "request body exceeds maximum upload size"})
		}
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
	}
	full := append(header, rest...)
	if err := h.store.PutModule(ctx, modulePath, version, bytes.NewReader(full)); err != nil {
		c.Logger().Errorf("failed to store module: %v", err)
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
	}

	return c.NoContent(http.StatusCreated)
}


Now update the tests. The `PUT /v1/modules/1.0.0` case (1 segment) still fails, and I should add the 2-segment case (`mymodule/1.0.0`) that bugbot describes:


		{
			// Only one segment: no module path before version.
			name:           "PUT /v1/modules/1.0.0 returns 400",
			path:           "/v1/modules/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},

		{
			// Only one segment: no module path before version.
			name:           "PUT /v1/modules/1.0.0 returns 400",
			path:           "/v1/modules/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Two segments: only one module path segment before version — below the
			// minimum of two, which would make ListVersions unreachable for this module.
			name:           "PUT /v1/modules/mymodule/1.0.0 returns 400",
			path:           "/v1/modules/mymodule/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},


The file /Users/steve.briggs/projects/operations/services/crow/internal/registry/handler_test.go has been updated successfully.

		{
			// Only one segment: no module path before version.
			name:           "PUT /v1/modules/1.0.0 returns 400",
			path:           "/v1/modules/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},
		{
			// Two segments: only one module path segment before version — below the
			// minimum of two, which would make ListVersions unreachable for this module.
			name:           "PUT /v1/modules/mymodule/1.0.0 returns 400",
			path:           "/v1/modules/mymodule/1.0.0",
			expectedStatus: http.StatusBadRequest,
		},

+      // Two segments: only one module path segment before version — below the

+      // minimum of two, which would make ListVersions unreachable for this module.

+      name:           "PUT /v1/modules/mymodule/1.0.0 returns 400",

+      path:           "/v1/modules/mymodule/1.0.0",

+      expectedStatus: http.StatusBadRequest,

// Segment with path traversal characters.

name:           "PUT with ../traversal segment returns 400",

path:           "/v1/modules/foo/../bar/aws/1.0.0",


Bash

Run all tests


?   	github.com/immuta/operations/services/crow	[no test files]
ok  	github.com/immuta/operations/services/crow/internal/config	(cached)
ok  	github.com/immuta/operations/services/crow/internal/registry	0.667s
ok  	github.com/immuta/operations/services/crow/internal/storage	(cached)


All passing. The fix in one sentence: `Download` and `Upload` now require `len(segments) < 3` (≥2 module path segments + version) instead of `< 2`, so the minimum depth is consistent across all three handlers. A module that can be uploaded can always have its versions listed.


you added validSegment but it is not used anywhere?


17:// validSegment matches a single path segment: starts with alphanumeric,
19:var validSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
25:		if !validSegment.MatchString(s) {


It is used — `validSegment` is referenced on line 25 inside `validateSegments`. The Go compiler would have caught it as unused if it weren't. Are you seeing a linter warning somewhere, or does it look unused in your editor? It's possible the linter is flagging `validateSegments` itself as unexported and unused from outside the package, but it is called from the three handlers within the same package.


i cannot read today, you are right it is being used


No worries, happens to everyone!


We're updating Crow's Terraform registry to accept module paths with any number of segments. All PR review feedback has been addressed and tests pass — the next step is to commit and push the changes. (disable recaps in /config)


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>Goodbye!</local-command-stdout>