Repository: picotechllc/spray
Pull request #1: refactor(server): improve path handling and add tests
Author: login: rcsheets
Created: 2025-03-11T06:21:28Z | Merged: 2025-03-16T07:07:37Z

- Add ObjectStore interface for better testability
- Create robust cleanRequestPath function
- Add comprehensive test suite for path handling
- Improve directory traversal prevention


Diff:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..e29541d
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,40 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+  schedule:
+    - cron: '24 18 * * 4'  # Run weekly on Thursday at 18:24 UTC
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'go' ]
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v3
+        with:
+          languages: ${{ matrix.language }}
+
+      - name: Autobuild
+        uses: github/codeql-action/autobuild@v3
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v3
+        with:
+          category: "/language:${{matrix.language}}" 
\ No newline at end of file
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..eb6a44b
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,31 @@
+name: Lint
+
+on:
+  pull_request:
+    branches: [ main ]
+  push:
+    branches: [ main ]
+
+jobs:
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '1.21'
+          cache: true
+
+      - name: Install staticcheck
+        run: go install honnef.co/go/tools/cmd/staticcheck@latest
+
+      - name: Run staticcheck
+        uses: reviewdog/action-staticcheck@v1
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          reporter: github-pr-review
+          filter_mode: nofilter
+          fail_level: none 
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..d5636e5
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,45 @@
+name: Test
+
+on:
+  pull_request:
+    branches: [ main ]
+  push:
+    branches: [ main ]
+    paths-ignore:
+      - '**'
+      - '!.github/workflows/**'
+
+jobs:
+  test:
+    name: Test with Coverage
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: '1.21'
+          cache: true
+
+      - name: Run Tests with Coverage
+        id: test
+        continue-on-error: true
+        run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
+
+      - name: Convert coverage to lcov
+        if: always()
+        uses: jandelgado/gcov2lcov-action@v1.0.9
+        with:
+          infile: coverage.txt
+          outfile: coverage.lcov
+
+      - name: Send coverage to Coveralls
+        if: always()
+        uses: coverallsapp/github-action@v2
+        with:
+          file: coverage.lcov
+
+      - name: Check test status
+        if: steps.test.outcome == 'failure'
+        run: exit 1 
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index fa44fa1..88f3559 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Use the official Golang image to create a build artifact.
-FROM golang:1.23.4 AS builder
+FROM golang:1.24.1 AS builder
 
 # Set the Current Working Directory inside the container
 WORKDIR /app
diff --git a/README.md b/README.md
index c7d635c..4aed9f0 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+[![Coverage Status](https://coveralls.io/repos/github/picotechllc/spray/badge.svg?branch=main)](https://coveralls.io/github/picotechllc/spray?branch=main)
+
 # Spray
 
 Spray is a minimal Go web server that serves the contents of a Google Cloud Platform (GCP) bucket.
@@ -7,6 +9,27 @@ Spray is a minimal Go web server that serves the contents of a Google Cloud Plat
 - Simple and lightweight
 - Serves static files from a GCP bucket
 - Easy to configure and deploy
+- Reasonably comprehensive Prometheus metrics
+
+### Metrics
+
+The following Prometheus metrics are exposed at `/metrics`:
+
+- `gcs_server_requests_total` - Total number of HTTP requests processed, labeled by bucket, path, method and status code
+- `gcs_server_request_duration_seconds` - HTTP request duration histogram, labeled by bucket, path and method
+- `gcs_server_bytes_transferred_total` - Total bytes transferred, labeled by bucket, path, method and direction
+- `gcs_server_active_requests` - Number of requests currently being processed, labeled by bucket
+- `gcs_server_cache_total` - Cache hit/miss counter (reserved for future use)
+- `gcs_server_errors_total` - Total number of errors encountered, labeled by bucket, path and error type
+- `gcs_server_object_size_bytes` - Size of objects served, labeled by bucket and path
+- `gcs_server_storage_operation_duration_seconds` - Latency of GCS operations, labeled by bucket and operation
+
+These metrics provide visibility into:
+- Request volume and latency
+- Error rates and types
+- Resource utilization
+- GCS operation performance
+
 
 ## Installation
 
diff --git a/go.mod b/go.mod
index 286aadc..a7167aa 100644
--- a/go.mod
+++ b/go.mod
@@ -19,9 +19,11 @@ require (
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/envoyproxy/go-control-plane v0.13.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -32,7 +34,16 @@ require (
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
 	github.com/googleapis/gax-go/v2 v2.14.0 // indirect
+	github.com/klauspost/compress v1.17.11 // indirect
+	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/prometheus/client_golang v1.21.1 // indirect
+	github.com/prometheus/client_model v0.6.1 // indirect
+	github.com/prometheus/common v0.62.0 // indirect
+	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/stretchr/testify v1.10.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
@@ -54,5 +65,6 @@ require (
 	google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
 	google.golang.org/grpc v1.67.3 // indirect
-	google.golang.org/protobuf v1.35.2 // indirect
+	google.golang.org/protobuf v1.36.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index ea2e7f4..d08c5c5 100644
--- a/go.sum
+++ b/go.sum
@@ -30,6 +30,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.48.1/go.mod h1:0wEl7vrAD8mehJyohS9HZy+WyEOaQO2mJx86Cvh93kM=
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 h1:8nn+rsCvTq9axyEh382S0PFLBeaFwNsT43IrPWzctRU=
 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
 github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
@@ -90,11 +92,25 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT
 github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
 github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
 github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
+github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -103,6 +119,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
 go.opentelemetry.io/contrib/detectors/gcp v1.29.0 h1:TiaiXB4DpGD3sdzNlYQxruQngn5Apwzi1X0DRhuGvDQ=
@@ -196,6 +214,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
 google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
+google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/main.go b/main.go
index 44306e3..f8d806b 100644
--- a/main.go
+++ b/main.go
@@ -2,189 +2,136 @@ package main
 
 import (
 	"context"
-	"expvar"
 	"flag"
 	"fmt"
-	"io"
 	"log"
 	"net/http"
 	"os"
 	"os/signal"
-	"sync"
 	"syscall"
 	"time"
 
 	"cloud.google.com/go/logging"
-	"cloud.google.com/go/storage"
+	"github.com/prometheus/client_golang/prometheus/promhttp"
 )
 
-type gcsServer struct {
-	bucket       *storage.BucketHandle
-	bucketName   string
-	objectCounts sync.Map
-	logger       *logging.Logger
+type config struct {
+	port       string
+	bucketName string
+	projectID  string
 }
 
-var (
-	objectsServed = expvar.NewInt("objects_served")
-	totalDuration = expvar.NewInt("total_duration_ms")
-)
-
-func newGCSServer(ctx context.Context, bucketName string, logger *logging.Logger) (*gcsServer, error) {
-	client, err := storage.NewClient(ctx)
-	if err != nil {
-		logger.Log(logging.Entry{
-			Severity: logging.Error,
-			Payload:  fmt.Sprintf("failed to create client: %v", err),
-		})
-		return nil, fmt.Errorf("failed to create client: %v", err)
-	}
-
-	return &gcsServer{
-		bucket:     client.Bucket(bucketName),
-		bucketName: bucketName,
-		logger:     logger,
-	}, nil
+// parseFlags parses command line flags and returns a config with just the flags set.
+// This should only be called once from main().
+func parseFlags() *config {
+	var cfg config
+	flag.StringVar(&cfg.port, "port", "8080", "Server port")
+	flag.Parse()
+	return &cfg
 }
 
-func (s *gcsServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	start := time.Now()
-	ctx := r.Context()
-	path := r.URL.Path
-
-	// Serve index.html for directory paths
-	if path == "/" || path[len(path)-1] == '/' {
-		path += "index.html"
-	} else {
-		// Remove leading slash
-		path = path[1:]
-	}
-
-	obj := s.bucket.Object(path)
-	reader, err := obj.NewReader(ctx)
-	if err != nil {
-		if err == storage.ErrObjectNotExist {
-			s.logger.Log(logging.Entry{
-				Severity: logging.Warning,
-				Payload:  fmt.Sprintf("Object %s not found: %v", path, err),
-			})
-			http.NotFound(w, r)
-			return
-		}
-		s.logger.Log(logging.Entry{
-			Severity: logging.Error,
-			Payload:  fmt.Sprintf("Error opening object %s: %v", path, err),
-		})
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	defer reader.Close()
-
-	// Set content type based on object attributes
-	attrs, err := obj.Attrs(ctx)
-	if err != nil {
-		s.logger.Log(logging.Entry{
-			Severity: logging.Error,
-			Payload:  fmt.Sprintf("Error getting attributes for object %s: %v", path, err),
-		})
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
+// loadConfig loads configuration from environment variables.
+// It takes an optional base config to extend (e.g., from command line flags).
+func loadConfig(base *config) (*config, error) {
+	cfg := &config{
+		port: "8080", // default value
 	}
-	w.Header().Set("Content-Type", attrs.ContentType)
-
-	// Copy the object contents to the response
-	if _, err := io.Copy(w, reader); err != nil {
-		s.logger.Log(logging.Entry{
-			Severity: logging.Error,
-			Payload:  fmt.Sprintf("Error copying object contents: %v", err),
-		})
+	if base != nil {
+		*cfg = *base
 	}
 
-	duration := time.Since(start).Milliseconds()
-	objectsServed.Add(1)
-	totalDuration.Add(duration)
-
-	// Update per-object counter
-	metricKey := fmt.Sprintf("%s/%s", s.bucketName, path)
-	count, _ := s.objectCounts.LoadOrStore(metricKey, new(expvar.Int))
-	count.(*expvar.Int).Add(1)
-}
-
-func readyzHandler(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("ok"))
-}
-
-func livezHandler(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("ok"))
-}
-
-func main() {
-	var bucketName string
-	var port string
-
-	flag.StringVar(&port, "port", "8080", "Server port")
-	flag.Parse()
-
-	bucketName = os.Getenv("BUCKET_NAME")
-	if bucketName == "" {
-		log.Fatal("Bucket name is required to serve objects")
+	cfg.bucketName = os.Getenv("BUCKET_NAME")
+	if cfg.bucketName == "" {
+		return nil, fmt.Errorf("BUCKET_NAME environment variable is required")
 	}
 
-	projectID := os.Getenv("GOOGLE_PROJECT_ID")
-	if projectID == "" {
-		log.Fatal("GOOGLE_PROJECT_ID is required to create logging client")
+	cfg.projectID = os.Getenv("GOOGLE_PROJECT_ID")
+	if cfg.projectID == "" {
+		return nil, fmt.Errorf("GOOGLE_PROJECT_ID environment variable is required")
 	}
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+	return cfg, nil
+}
 
-	log.Printf("Using bucket: %s", bucketName)
+// ServerSetup is a function type for setting up the HTTP server
+type ServerSetup func(context.Context, *config) (*http.Server, error)
 
-	client, err := logging.NewClient(ctx, projectID)
+// setupServer is the default server setup implementation
+var setupServer ServerSetup = func(ctx context.Context, cfg *config) (*http.Server, error) {
+	client, err := logging.NewClient(ctx, cfg.projectID)
 	if err != nil {
-		log.Fatalf("Failed to create logging client: %v", err)
+		return nil, fmt.Errorf("failed to create logging client: %v", err)
 	}
-	defer client.Close()
 
 	logger := client.Logger("gcs-server")
 
-	server, err := newGCSServer(ctx, bucketName, logger)
+	server, err := newGCSServer(ctx, cfg.bucketName, logger)
 	if err != nil {
-		log.Fatalf("Error creating GCS server: %v", err)
+		client.Close()
+		return nil, fmt.Errorf("failed to create GCS server: %v", err)
 	}
 
 	mux := http.NewServeMux()
 	mux.Handle("/", server)
-	mux.Handle("/metrics", expvar.Handler())
+	mux.Handle("/metrics", promhttp.Handler())
 	mux.HandleFunc("/readyz", readyzHandler)
 	mux.HandleFunc("/livez", livezHandler)
 
-	srv := &http.Server{
-		Addr:    ":" + port,
+	return &http.Server{
+		Addr:    ":" + cfg.port,
 		Handler: mux,
-	}
+	}, nil
+}
+
+func run(ctx context.Context, srv *http.Server) error {
+	// Channel to listen for errors coming from the listener.
+	serverErrors := make(chan error, 1)
 
-	// Graceful shutdown
+	// Start the server
 	go func() {
-		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
-			log.Fatalf("ListenAndServe(): %v", err)
-		}
+		log.Printf("Server started on port %s", srv.Addr)
+		serverErrors <- srv.ListenAndServe()
 	}()
-	log.Printf("Server started on port %s", port)
 
-	quit := make(chan os.Signal, 1)
-	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
-	<-quit
-	log.Println("Shutting down server...")
+	// Channel to listen for an interrupt or terminate signal from the OS.
+	shutdown := make(chan os.Signal, 1)
+	signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
+
+	// Blocking select waiting for either a server error or a signal.
+	select {
+	case err := <-serverErrors:
+		return fmt.Errorf("server error: %v", err)
+	case <-shutdown:
+		log.Println("Starting shutdown...")
+
+		// Give outstanding requests a deadline for completion.
+		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+		defer cancel()
+
+		// Asking listener to shut down and shed load.
+		if err := srv.Shutdown(ctx); err != nil {
+			// Error from closing listeners, or context timeout.
+			return fmt.Errorf("graceful shutdown failed: %v", err)
+		}
+	}
+
+	return nil
+}
 
-	ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
+func main() {
+	flagCfg := parseFlags()
+	cfg, err := loadConfig(flagCfg)
+	if err != nil {
+		log.Fatal(err)
+	}
 
-	if err := srv.Shutdown(ctxShutdown); err != nil {
-		log.Fatalf("Server forced to shutdown: %v", err)
+	ctx := context.Background()
+	srv, err := setupServer(ctx, cfg)
+	if err != nil {
+		log.Fatal(err)
 	}
 
-	log.Println("Server exiting")
+	if err := run(ctx, srv); err != nil {
+		log.Fatal(err)
+	}
 }
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..896352a
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,253 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// mockServer implements http.Handler for testing
+type mockServer struct{}
+
+func (s *mockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	w.WriteHeader(http.StatusOK)
+}
+
+func TestLoadConfig(t *testing.T) {
+	tests := []struct {
+		name       string
+		basePort   string
+		expectPort string
+		envs       map[string]string
+		wantErr    bool
+	}{
+		{
+			name:       "valid config with default port",
+			expectPort: "8080",
+			envs: map[string]string{
+				"BUCKET_NAME":       "test-bucket",
+				"GOOGLE_PROJECT_ID": "test-project",
+			},
+		},
+		{
+			name:       "valid config with custom port",
+			basePort:   "9090",
+			expectPort: "9090",
+			envs: map[string]string{
+				"BUCKET_NAME":       "test-bucket",
+				"GOOGLE_PROJECT_ID": "test-project",
+			},
+		},
+		{
+			name: "missing bucket name",
+			envs: map[string]string{
+				"GOOGLE_PROJECT_ID": "test-project",
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing project ID",
+			envs: map[string]string{
+				"BUCKET_NAME": "test-bucket",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Clear environment variables
+			os.Unsetenv("BUCKET_NAME")
+			os.Unsetenv("GOOGLE_PROJECT_ID")
+
+			// Set test environment variables
+			for k, v := range tt.envs {
+				os.Setenv(k, v)
+			}
+
+			// Create base config if port is specified
+			var base *config
+			if tt.basePort != "" {
+				base = &config{port: tt.basePort}
+			}
+
+			cfg, err := loadConfig(base)
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+
+			require.NoError(t, err)
+			assert.Equal(t, tt.expectPort, cfg.port)
+			assert.Equal(t, tt.envs["BUCKET_NAME"], cfg.bucketName)
+			assert.Equal(t, tt.envs["GOOGLE_PROJECT_ID"], cfg.projectID)
+		})
+	}
+}
+
+func TestServerSetup(t *testing.T) {
+	tests := []struct {
+		name      string
+		setup     ServerSetup
+		cfg       *config
+		wantPort  string
+		wantPaths []string
+		wantErr   bool
+	}{
+		{
+			name: "successful setup",
+			setup: func(ctx context.Context, cfg *config) (*http.Server, error) {
+				mux := http.NewServeMux()
+				mux.Handle("/", &mockServer{})
+				mux.Handle("/metrics", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.WriteHeader(http.StatusOK)
+				}))
+				mux.HandleFunc("/readyz", readyzHandler)
+				mux.HandleFunc("/livez", livezHandler)
+
+				return &http.Server{
+					Addr:    ":" + cfg.port,
+					Handler: mux,
+				}, nil
+			},
+			cfg: &config{
+				port:       "8080",
+				bucketName: "test-bucket",
+				projectID:  "test-project",
+			},
+			wantPort: ":8080",
+			wantPaths: []string{
+				"/",
+				"/metrics",
+				"/readyz",
+				"/livez",
+			},
+			wantErr: false,
+		},
+		{
+			name: "setup error",
+			setup: func(ctx context.Context, cfg *config) (*http.Server, error) {
+				return nil, fmt.Errorf("mock setup error")
+			},
+			cfg: &config{
+				port:       "8080",
+				bucketName: "test-bucket",
+				projectID:  "test-project",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Save original setupServer and restore after test
+			originalSetup := setupServer
+			defer func() { setupServer = originalSetup }()
+
+			// Set test setup function
+			setupServer = tt.setup
+
+			// Run the setup
+			srv, err := setupServer(context.Background(), tt.cfg)
+			if tt.wantErr {
+				assert.Error(t, err)
+				return
+			}
+
+			require.NoError(t, err)
+			assert.Equal(t, tt.wantPort, srv.Addr)
+
+			// Test that all expected paths are registered
+			if len(tt.wantPaths) > 0 {
+				mux, ok := srv.Handler.(*http.ServeMux)
+				require.True(t, ok, "Handler should be *http.ServeMux")
+
+				for _, path := range tt.wantPaths {
+					h, pattern := mux.Handler(&http.Request{URL: &url.URL{Path: path}})
+					assert.NotNil(t, h, "Handler should be registered for path %s", path)
+					assert.Equal(t, path, pattern, "Path %s should be registered", path)
+				}
+			}
+		})
+	}
+}
+
+func TestRun(t *testing.T) {
+	// Save original setupServer and restore after test
+	originalSetup := setupServer
+	defer func() { setupServer = originalSetup }()
+
+	// Create mock setup function
+	setupServer = func(ctx context.Context, cfg *config) (*http.Server, error) {
+		mux := http.NewServeMux()
+		mux.Handle("/", &mockServer{})
+		return &http.Server{
+			Addr:    ":0",
+			Handler: mux,
+		}, nil
+	}
+
+	// Create a context with a short timeout
+	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+	defer cancel()
+
+	// Create a channel to signal server start
+	started := make(chan struct{})
+
+	// Create a channel to receive any server errors
+	errChan := make(chan error, 1)
+
+	// Start the server in a goroutine
+	go func() {
+		cfg := &config{
+			port:       ":0",
+			bucketName: "test-bucket",
+			projectID:  "test-project",
+		}
+
+		// Create server with mock setup
+		srv, err := setupServer(ctx, cfg)
+		if err != nil {
+			errChan <- fmt.Errorf("failed to setup server: %v", err)
+			return
+		}
+
+		// Get the actual port assigned by the OS
+		listener, err := net.Listen("tcp", ":0")
+		if err != nil {
+			errChan <- fmt.Errorf("failed to listen: %v", err)
+			return
+		}
+		defer listener.Close()
+
+		// Update server address with actual listener address
+		srv.Addr = listener.Addr().String()
+
+		// Signal that we're ready to serve
+		close(started)
+
+		// Serve with the listener
+		if err := srv.Serve(listener); err != http.ErrServerClosed {
+			errChan <- fmt.Errorf("server error: %v", err)
+		}
+	}()
+
+	// Wait for server to start or timeout
+	select {
+	case err := <-errChan:
+		t.Fatalf("server failed to start: %v", err)
+	case <-started:
+		// Server started successfully
+		cancel() // Trigger shutdown after successful start
+	case <-ctx.Done():
+		t.Fatal("timeout waiting for server to start")
+	}
+}
diff --git a/metrics.go b/metrics.go
new file mode 100644
index 0000000..5257f38
--- /dev/null
+++ b/metrics.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	// requestsTotal counts the total number of requests handled by the server
+	requestsTotal = promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "gcs_server_requests_total",
+			Help: "Total number of requests handled by the GCS server",
+		},
+		[]string{"bucket_name", "path", "method", "status"},
+	)
+
+	// requestDuration tracks request duration in seconds
+	requestDuration = promauto.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Name:    "gcs_server_request_duration_seconds",
+			Help:    "Duration of requests in seconds",
+			Buckets: prometheus.DefBuckets,
+		},
+		[]string{"bucket_name", "path", "method"},
+	)
+
+	// bytesTransferred tracks the number of bytes transferred
+	bytesTransferred = promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "gcs_server_bytes_transferred_total",
+			Help: "Total number of bytes transferred",
+		},
+		[]string{"bucket_name", "path", "method", "direction"}, // direction can be "upload" or "download"
+	)
+
+	// activeRequests tracks the number of currently active requests
+	activeRequests = promauto.NewGaugeVec(
+		prometheus.GaugeOpts{
+			Name: "gcs_server_active_requests",
+			Help: "Number of currently active requests",
+		},
+		[]string{"bucket_name"},
+	)
+
+	// cacheStatus tracks cache hits and misses (for future use)
+	cacheStatus = promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "gcs_server_cache_total",
+			Help: "Total number of cache hits/misses",
+		},
+		[]string{"bucket_name", "path", "status"}, // status: hit/miss
+	)
+
+	// errorTotal tracks specific error types
+	errorTotal = promauto.NewCounterVec(
+		prometheus.CounterOpts{
+			Name: "gcs_server_errors_total",
+			Help: "Total number of errors by type",
+		},
+		[]string{"bucket_name", "path", "error_type"}, // error_type: storage_error, invalid_path, etc.
+	)
+
+	// objectSize tracks the size distribution of served objects
+	objectSize = promauto.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Name:    "gcs_server_object_size_bytes",
+			Help:    "Distribution of served object sizes in bytes",
+			Buckets: prometheus.ExponentialBuckets(1024, 2, 10), // 1KB to 1GB
+		},
+		[]string{"bucket_name", "path"},
+	)
+
+	// gcsLatency tracks GCS operation latency
+	gcsLatency = promauto.NewHistogramVec(
+		prometheus.HistogramOpts{
+			Name:    "gcs_server_storage_operation_duration_seconds",
+			Help:    "Duration of GCS operations in seconds",
+			Buckets: prometheus.DefBuckets,
+		},
+		[]string{"bucket_name", "operation"}, // operation: get_object, get_attrs
+	)
+)
diff --git a/metrics_test.go b/metrics_test.go
new file mode 100644
index 0000000..54b5e42
--- /dev/null
+++ b/metrics_test.go
@@ -0,0 +1,197 @@
+package main
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMetricsRegistration(t *testing.T) {
+	// Test that metrics are registered with correct names
+	metrics := []struct {
+		collector prometheus.Collector
+		name      string
+	}{
+		{
+			collector: requestsTotal,
+			name:      "gcs_server_requests_total",
+		},
+		{
+			collector: requestDuration,
+			name:      "gcs_server_request_duration_seconds",
+		},
+		{
+			collector: bytesTransferred,
+			name:      "gcs_server_bytes_transferred_total",
+		},
+		{
+			collector: activeRequests,
+			name:      "gcs_server_active_requests",
+		},
+		{
+			collector: cacheStatus,
+			name:      "gcs_server_cache_total",
+		},
+		{
+			collector: errorTotal,
+			name:      "gcs_server_errors_total",
+		},
+		{
+			collector: objectSize,
+			name:      "gcs_server_object_size_bytes",
+		},
+		{
+			collector: gcsLatency,
+			name:      "gcs_server_storage_operation_duration_seconds",
+		},
+	}
+
+	for _, m := range metrics {
+		t.Run(m.name, func(t *testing.T) {
+			// Verify the metric exists in the default registry
+			assert.True(t, testutil.CollectAndCount(m.collector) >= 0)
+		})
+	}
+}
+
+func TestMetricsBehavior(t *testing.T) {
+	const testBucket = "test-bucket"
+
+	t.Run("requestsTotal", func(t *testing.T) {
+		// Reset all metrics before test
+		prometheus.DefaultRegisterer.Unregister(requestsTotal)
+		requestsTotal = promauto.NewCounterVec(
+			prometheus.CounterOpts{
+				Name: "gcs_server_requests_total",
+				Help: "Total number of requests handled by the GCS server",
+			},
+			[]string{"bucket_name", "path", "method", "status"},
+		)
+
+		// Test incrementing counter
+		requestsTotal.WithLabelValues(testBucket, "/test", "GET", "200").Inc()
+
+		value := testutil.ToFloat64(requestsTotal.WithLabelValues(testBucket, "/test", "GET", "200"))
+		assert.Equal(t, float64(1), value)
+	})
+
+	t.Run("activeRequests", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(activeRequests)
+		activeRequests = promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Name: "gcs_server_active_requests",
+				Help: "Number of currently active requests",
+			},
+			[]string{"bucket_name"},
+		)
+
+		// Test gauge behavior
+		activeRequests.WithLabelValues(testBucket).Inc()
+		assert.Equal(t, float64(1), testutil.ToFloat64(activeRequests.WithLabelValues(testBucket)))
+
+		activeRequests.WithLabelValues(testBucket).Dec()
+		assert.Equal(t, float64(0), testutil.ToFloat64(activeRequests.WithLabelValues(testBucket)))
+	})
+
+	t.Run("bytesTransferred", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(bytesTransferred)
+		bytesTransferred = promauto.NewCounterVec(
+			prometheus.CounterOpts{
+				Name: "gcs_server_bytes_transferred_total",
+				Help: "Total number of bytes transferred",
+			},
+			[]string{"bucket_name", "path", "method", "direction"},
+		)
+
+		// Test adding bytes
+		bytesTransferred.WithLabelValues(testBucket, "/test", "GET", "download").Add(100)
+
+		value := testutil.ToFloat64(bytesTransferred.WithLabelValues(testBucket, "/test", "GET", "download"))
+		assert.Equal(t, float64(100), value)
+	})
+
+	t.Run("requestDuration", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(requestDuration)
+		requestDuration = promauto.NewHistogramVec(
+			prometheus.HistogramOpts{
+				Name:    "gcs_server_request_duration_seconds",
+				Help:    "Duration of requests in seconds",
+				Buckets: prometheus.DefBuckets,
+			},
+			[]string{"bucket_name", "path", "method"},
+		)
+
+		// Test observing durations
+		requestDuration.WithLabelValues(testBucket, "/test", "GET").Observe(0.1)
+
+		// Verify the histogram has samples
+		count := testutil.CollectAndCount(requestDuration)
+		assert.Equal(t, 1, count)
+	})
+
+	t.Run("errorTotal", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(errorTotal)
+		errorTotal = promauto.NewCounterVec(
+			prometheus.CounterOpts{
+				Name: "gcs_server_errors_total",
+				Help: "Total number of errors by type",
+			},
+			[]string{"bucket_name", "path", "error_type"},
+		)
+
+		// Test error counting
+		errorTotal.WithLabelValues(testBucket, "/test", "storage_error").Inc()
+		errorTotal.WithLabelValues(testBucket, "/test", "invalid_path").Inc()
+		errorTotal.WithLabelValues(testBucket, "/test", "object_not_found").Inc()
+
+		value := testutil.ToFloat64(errorTotal.WithLabelValues(testBucket, "/test", "storage_error"))
+		assert.Equal(t, float64(1), value)
+	})
+
+	t.Run("objectSize", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(objectSize)
+		objectSize = promauto.NewHistogramVec(
+			prometheus.HistogramOpts{
+				Name:    "gcs_server_object_size_bytes",
+				Help:    "Distribution of served object sizes in bytes",
+				Buckets: prometheus.ExponentialBuckets(1024, 2, 10),
+			},
+			[]string{"bucket_name", "path"},
+		)
+
+		// Test size observation
+		objectSize.WithLabelValues(testBucket, "/test").Observe(2048)
+
+		// Verify the histogram has samples
+		count := testutil.CollectAndCount(objectSize)
+		assert.Equal(t, 1, count)
+	})
+
+	t.Run("gcsLatency", func(t *testing.T) {
+		// Reset metric before test
+		prometheus.DefaultRegisterer.Unregister(gcsLatency)
+		gcsLatency = promauto.NewHistogramVec(
+			prometheus.HistogramOpts{
+				Name:    "gcs_server_storage_operation_duration_seconds",
+				Help:    "Duration of GCS operations in seconds",
+				Buckets: prometheus.DefBuckets,
+			},
+			[]string{"bucket_name", "operation"},
+		)
+
+		// Test latency observation
+		gcsLatency.WithLabelValues(testBucket, "get_object").Observe(0.05)
+
+		// Verify the histogram has samples
+		count := testutil.CollectAndCount(gcsLatency)
+		assert.Equal(t, 1, count)
+	})
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..5624d9c
--- /dev/null
+++ b/server.go
@@ -0,0 +1,216 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/logging"
+	"cloud.google.com/go/storage"
+)
+
+// ObjectStore defines the interface for storage operations
+type ObjectStore interface {
+	GetObject(ctx context.Context, path string) (io.ReadCloser, *storage.ObjectAttrs, error)
+}
+
+// GCSObjectStore implements ObjectStore using Google Cloud Storage
+type GCSObjectStore struct {
+	bucket *storage.BucketHandle
+}
+
+func (s *GCSObjectStore) GetObject(ctx context.Context, path string) (io.ReadCloser, *storage.ObjectAttrs, error) {
+	obj := s.bucket.Object(path)
+	reader, err := obj.NewReader(ctx)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	attrs, err := obj.Attrs(ctx)
+	if err != nil {
+		reader.Close()
+		return nil, nil, err
+	}
+
+	return reader, attrs, nil
+}
+
+type gcsServer struct {
+	store      ObjectStore
+	bucketName string
+	logger     *logging.Logger
+}
+
+func newGCSServer(ctx context.Context, bucketName string, logger *logging.Logger) (*gcsServer, error) {
+	client, err := storage.NewClient(ctx)
+	if err != nil {
+		logger.Log(logging.Entry{
+			Severity: logging.Error,
+			Payload: map[string]any{
+				"error":     err.Error(),
+				"operation": "create_storage_client",
+			},
+		})
+		return nil, fmt.Errorf("failed to create client: %v", err)
+	}
+
+	store := &GCSObjectStore{
+		bucket: client.Bucket(bucketName),
+	}
+
+	return &gcsServer{
+		store:      store,
+		bucketName: bucketName,
+		logger:     logger,
+	}, nil
+}
+
+// cleanRequestPath normalizes and validates the request path.
+// It handles:
+// 1. URL decoding
+// 2. Multiple slashes removal
+// 3. Directory index handling
+// 4. Directory traversal prevention
+// Returns the cleaned path and an error if the path is invalid
+func cleanRequestPath(path string) (string, error) {
+	// URL decode the path
+	decodedPath, err := url.PathUnescape(path)
+	if err != nil {
+		return "", fmt.Errorf("error decoding path: %v", err)
+	}
+
+	// Handle root path
+	if decodedPath == "/" {
+		return "index.html", nil
+	}
+
+	// Remove leading slash and normalize multiple slashes
+	parts := strings.Split(decodedPath, "/")
+	var normalizedParts []string
+	for _, part := range parts {
+		if part != "" {
+			normalizedParts = append(normalizedParts, part)
+		}
+	}
+
+	// Handle empty path
+	if len(normalizedParts) == 0 {
+		return "index.html", nil
+	}
+
+	// Join parts and handle directory paths
+	cleanPath := strings.Join(normalizedParts, "/")
+	if decodedPath[len(decodedPath)-1] == '/' {
+		cleanPath += "/index.html"
+	}
+
+	// Prevent directory traversal
+	if strings.Contains(cleanPath, "..") {
+		return "", fmt.Errorf("invalid path: directory traversal attempt")
+	}
+
+	return cleanPath, nil
+}
+
+func (s *gcsServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	start := time.Now()
+	ctx := r.Context()
+
+	// Track active requests
+	activeRequests.WithLabelValues(s.bucketName).Inc()
+	defer activeRequests.WithLabelValues(s.bucketName).Dec()
+
+	cleanPath, err := cleanRequestPath(r.URL.Path)
+	if err != nil {
+		s.logger.Log(logging.Entry{
+			Severity: logging.Error,
+			Payload: map[string]any{
+				"error":     err.Error(),
+				"path":      r.URL.Path,
+				"operation": "clean_path",
+			},
+		})
+		errorTotal.WithLabelValues(s.bucketName, r.URL.Path, "invalid_path").Inc()
+		requestsTotal.WithLabelValues(s.bucketName, r.URL.Path, r.Method, "400").Inc()
+		http.Error(w, "Invalid path", http.StatusBadRequest)
+		return
+	}
+
+	// Track GCS operations timing
+	gcsStart := time.Now()
+	reader, attrs, err := s.store.GetObject(ctx, cleanPath)
+	gcsLatency.WithLabelValues(s.bucketName, "get_object").Observe(time.Since(gcsStart).Seconds())
+
+	if err != nil {
+		if err == storage.ErrObjectNotExist {
+			s.logger.Log(logging.Entry{
+				Severity: logging.Warning,
+				Payload: map[string]any{
+					"error":     err.Error(),
+					"path":      cleanPath,
+					"operation": "get_object",
+					"status":    http.StatusNotFound,
+				},
+			})
+			errorTotal.WithLabelValues(s.bucketName, cleanPath, "object_not_found").Inc()
+			requestsTotal.WithLabelValues(s.bucketName, cleanPath, r.Method, "404").Inc()
+			http.NotFound(w, r)
+			return
+		}
+		s.logger.Log(logging.Entry{
+			Severity: logging.Error,
+			Payload: map[string]any{
+				"error":     err.Error(),
+				"path":      cleanPath,
+				"operation": "get_object",
+				"status":    http.StatusInternalServerError,
+			},
+		})
+		errorTotal.WithLabelValues(s.bucketName, cleanPath, "storage_error").Inc()
+		requestsTotal.WithLabelValues(s.bucketName, cleanPath, r.Method, "500").Inc()
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer reader.Close()
+
+	// Track object size
+	objectSize.WithLabelValues(s.bucketName, cleanPath).Observe(float64(attrs.Size))
+
+	w.Header().Set("Content-Type", attrs.ContentType)
+
+	// Copy the object contents to the response while tracking bytes transferred
+	written, err := io.Copy(w, reader)
+	if err != nil {
+		s.logger.Log(logging.Entry{
+			Severity: logging.Error,
+			Payload: map[string]any{
+				"error":     err.Error(),
+				"path":      cleanPath,
+				"operation": "copy_contents",
+			},
+		})
+		errorTotal.WithLabelValues(s.bucketName, cleanPath, "copy_error").Inc()
+		requestsTotal.WithLabelValues(s.bucketName, cleanPath, r.Method, "500").Inc()
+	} else {
+		requestsTotal.WithLabelValues(s.bucketName, cleanPath, r.Method, "200").Inc()
+		bytesTransferred.WithLabelValues(s.bucketName, cleanPath, r.Method, "download").Add(float64(written))
+	}
+
+	// Record request duration
+	duration := time.Since(start).Seconds()
+	requestDuration.WithLabelValues(s.bucketName, cleanPath, r.Method).Observe(duration)
+}
+
+func readyzHandler(w http.ResponseWriter, r *http.Request) {
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte("ok"))
+}
+
+func livezHandler(w http.ResponseWriter, r *http.Request) {
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte("ok"))
+}
diff --git a/server_test.go b/server_test.go
new file mode 100644
index 0000000..c317069
--- /dev/null
+++ b/server_test.go
@@ -0,0 +1,269 @@
+package main
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"cloud.google.com/go/logging"
+	"cloud.google.com/go/storage"
+	"google.golang.org/api/option"
+)
+
+// Mock implementations
+type mockObject struct {
+	data        []byte
+	contentType string
+}
+
+type mockReader struct {
+	data   []byte
+	offset int64
+}
+
+func (r *mockReader) Read(p []byte) (int, error) {
+	if r.offset >= int64(len(r.data)) {
+		return 0, io.EOF
+	}
+	n := copy(p, r.data[r.offset:])
+	r.offset += int64(n)
+	return n, nil
+}
+
+func (r *mockReader) Close() error {
+	return nil
+}
+
+type mockObjectStore struct {
+	objects map[string]mockObject
+}
+
+func (s *mockObjectStore) GetObject(ctx context.Context, path string) (io.ReadCloser, *storage.ObjectAttrs, error) {
+	obj, exists := s.objects[path]
+	if !exists {
+		return nil, nil, storage.ErrObjectNotExist
+	}
+	return &mockReader{data: obj.data}, &storage.ObjectAttrs{
+		ContentType: obj.contentType,
+	}, nil
+}
+
+func TestPathHandling(t *testing.T) {
+	tests := []struct {
+		name         string
+		path         string
+		expectedPath string
+		expectedCode int
+		objectExists bool
+		contentType  string
+		content      string
+	}{
+		{
+			name:         "Root path",
+			path:         "/",
+			expectedPath: "index.html",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/html",
+			content:      "<html><body>Index</body></html>",
+		},
+		{
+			name:         "Directory path with trailing slash",
+			path:         "/docs/",
+			expectedPath: "docs/index.html",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/html",
+			content:      "<html><body>Docs Index</body></html>",
+		},
+		{
+			name:         "Directory path without trailing slash",
+			path:         "/docs",
+			expectedPath: "docs",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/html",
+			content:      "<html><body>Docs</body></html>",
+		},
+		{
+			name:         "File in subdirectory",
+			path:         "/css/styles.css",
+			expectedPath: "css/styles.css",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/css",
+			content:      "body { color: blue; }",
+		},
+		{
+			name:         "Non-existent file",
+			path:         "/notfound.html",
+			expectedPath: "notfound.html",
+			expectedCode: http.StatusNotFound,
+			objectExists: false,
+		},
+		{
+			name:         "Path with multiple slashes",
+			path:         "//multiple///slashes/file.txt",
+			expectedPath: "multiple/slashes/file.txt",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/plain",
+			content:      "test content",
+		},
+		{
+			name:         "Path with special characters",
+			path:         "/path%20with%20spaces.txt",
+			expectedPath: "path with spaces.txt",
+			expectedCode: http.StatusOK,
+			objectExists: true,
+			contentType:  "text/plain",
+			content:      "content with spaces",
+		},
+	}
+
+	ctx := context.Background()
+
+	// Create a mock logging client
+	logClient, err := logging.NewClient(ctx, "test-project", option.WithoutAuthentication())
+	if err != nil {
+		t.Fatalf("Failed to create mock logging client: %v", err)
+	}
+	defer logClient.Close()
+	logger := logClient.Logger("test-logger")
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create mock store with test objects
+			mockStore := &mockObjectStore{
+				objects: make(map[string]mockObject),
+			}
+
+			if tt.objectExists {
+				mockStore.objects[tt.expectedPath] = mockObject{
+					data:        []byte(tt.content),
+					contentType: tt.contentType,
+				}
+			}
+
+			server := &gcsServer{
+				store:      mockStore,
+				bucketName: "test-bucket",
+				logger:     logger,
+			}
+
+			req := httptest.NewRequest("GET", tt.path, nil)
+			w := httptest.NewRecorder()
+
+			server.ServeHTTP(w, req)
+
+			if w.Code != tt.expectedCode {
+				t.Errorf("Expected status code %d, got %d", tt.expectedCode, w.Code)
+			}
+
+			if tt.objectExists {
+				if got := w.Header().Get("Content-Type"); got != tt.contentType {
+					t.Errorf("Expected Content-Type %q, got %q", tt.contentType, got)
+				}
+
+				if got := w.Body.String(); got != tt.content {
+					t.Errorf("Expected content %q, got %q", tt.content, got)
+				}
+			}
+		})
+	}
+}
+
+func TestCleanRequestPath(t *testing.T) {
+	tests := []struct {
+		name          string
+		path          string
+		expectedPath  string
+		expectError   bool
+		errorContains string
+	}{
+		{
+			name:         "Root path",
+			path:         "/",
+			expectedPath: "index.html",
+		},
+		{
+			name:         "Directory path with trailing slash",
+			path:         "/docs/",
+			expectedPath: "docs/index.html",
+		},
+		{
+			name:         "Directory path without trailing slash",
+			path:         "/docs",
+			expectedPath: "docs",
+		},
+		{
+			name:         "File in subdirectory",
+			path:         "/css/styles.css",
+			expectedPath: "css/styles.css",
+		},
+		{
+			name:         "Multiple slashes",
+			path:         "//multiple///slashes/file.txt",
+			expectedPath: "multiple/slashes/file.txt",
+		},
+		{
+			name:         "URL encoded path",
+			path:         "/path%20with%20spaces.txt",
+			expectedPath: "path with spaces.txt",
+		},
+		{
+			name:         "Empty path",
+			path:         "",
+			expectedPath: "index.html",
+		},
+		{
+			name:          "Directory traversal attempt",
+			path:          "../secret.txt",
+			expectError:   true,
+			errorContains: "directory traversal",
+		},
+		{
+			name:          "Directory traversal with encoded slashes",
+			path:          "/..%2F..%2Fsecret.txt",
+			expectError:   true,
+			errorContains: "directory traversal",
+		},
+		{
+			name:         "Deep nested path",
+			path:         "/a/b/c/d/file.txt",
+			expectedPath: "a/b/c/d/file.txt",
+		},
+		{
+			name:         "All slashes",
+			path:         "////",
+			expectedPath: "index.html",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cleanPath, err := cleanRequestPath(tt.path)
+
+			if tt.expectError {
+				if err == nil {
+					t.Error("Expected error but got none")
+				} else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+					t.Errorf("Expected error containing %q but got %q", tt.errorContains, err.Error())
+				}
+				return
+			}
+
+			if err != nil {
+				t.Errorf("Unexpected error: %v", err)
+				return
+			}
+
+			if cleanPath != tt.expectedPath {
+				t.Errorf("Expected path %q but got %q", tt.expectedPath, cleanPath)
+			}
+		})
+	}
+}


Reviews:
user: login: grue
state: APPROVED
author_association: NONE
submitted_at: 2025-03-16T04:44:49Z

Review comments:

PR comments:
user: login: github-advanced-security[bot]
created_at: 2025-03-11T06:40:28Z
body: This pull request sets up GitHub code scanning for this repository. Once the scans have completed and the checks have passed, the analysis results for this pull request branch will appear on [this overview](/picotechllc/spray/security/code-scanning?query=pr%3A1+is%3Aopen). Once you merge this pull request, the 'Security' tab will show more code scanning analysis results (for example, for the default branch). Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results. For more information about GitHub code scanning, check out [the documentation](https://docs.github.com/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning). 
author_association: NONE
user: login: grue
created_at: 2025-03-15T20:46:20Z
body: Have you considered https://github.com/spf13/cobra for arg parsing, flags, env var, and config wrangling? 
author_association: NONE
user: login: rcsheets
created_at: 2025-03-16T01:19:16Z
body: > Have you considered https://github.com/spf13/cobra for arg parsing, flags, env var, and config wrangling?

I have not. That could certainly go into a separate PR. This one is already bigger than I wanted.
author_association: CONTRIBUTOR