You are implementing Task 4 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits: Task 1 (`ae477d5`), Task 2 + fixes (`3b73e14`, `59cd0b6`), Task 3 (`ee7794e`)
- Use absolute paths or `cd` into the worktree. **Never** operate on the main repo.
- **NEVER push.** **NEVER use `git add .` or `git add -A`.** Always `git add` specific paths.

## Context

This task creates a NEW internal package `internal/archive` in `apps/birdseye/` that extracts gzipped tar archives into an in-memory file map. Birdseye fetches module tarballs from Crow and needs to enumerate their files for diffing. This is the happy-path implementation; Task 5 (next) adds hardening tests (zip-slip, size cap, symlinks).

The plan deliberately uses stdlib `archive/tar` + `compress/gzip` — no third-party archive lib. The output type `Module` has a `Files map[string][]byte` keyed by forward-slash relative path.

Use strict TDD: write the failing tests first, run them, then implement.

## Task Description (Verbatim)

### Task 4: `internal/archive` — tar.gz extraction (happy path)

**Files:**
- Create: `apps/birdseye/internal/archive/extract.go`
- Create: `apps/birdseye/internal/archive/extract_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/archive/extract_test.go`:

```go
package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"testing"
)

// buildTarGz produces a gzipped tar with the given files in memory.
// Used by archive, diff, and registry tests.
func buildTarGz(t *testing.T, files map[string]string) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for name, body := range files {
		hdr := &tar.Header{
			Name: name,
			Mode: 0o644,
			Size: int64(len(body)),
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatalf("WriteHeader %s: %v", name, err)
		}
		if _, err := tw.Write([]byte(body)); err != nil {
			t.Fatalf("Write %s: %v", name, err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gz.Close(); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

func TestExtractHappyPath(t *testing.T) {
	in := map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"x\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
		"README.md":    "hello\n",
	}
	tgz := buildTarGz(t, in)

	mod, err := Extract(tgz, 1<<20)
	if err != nil {
		t.Fatalf("Extract: %v", err)
	}
	if len(mod.Files) != len(in) {
		t.Fatalf("got %d files, want %d", len(mod.Files), len(in))
	}
	for name, want := range in {
		got, ok := mod.Files[name]
		if !ok {
			t.Errorf("missing file: %s", name)
			continue
		}
		if string(got) != want {
			t.Errorf("%s body = %q, want %q", name, got, want)
		}
	}
}

func TestExtractEmptyArchive(t *testing.T) {
	tgz := buildTarGz(t, nil)
	mod, err := Extract(tgz, 1<<20)
	if err != nil {
		t.Fatalf("Extract empty: %v", err)
	}
	if len(mod.Files) != 0 {
		t.Errorf("expected 0 files, got %d", len(mod.Files))
	}
}

func TestExtractMalformedGzip(t *testing.T) {
	_, err := Extract([]byte("not a gzip"), 1<<20)
	if err == nil {
		t.Fatal("expected error on malformed input")
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`undefined: Extract`)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/archive/...`
Expected: compile error.

- [ ] **Step 3: Implement `apps/birdseye/internal/archive/extract.go`**

```go
// Package archive extracts gzipped tar archives from Crow into an
// in-memory file map. Diff and TUI layers consume the resulting Module
// type and never touch the network themselves.
package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
)

// Module is the in-memory representation of an extracted module tarball.
// Keys are forward-slash relative paths. Symlinks are not stored.
type Module struct {
	Files map[string][]byte
}

var (
	ErrTooLarge   = errors.New("archive exceeds size cap")
	ErrUnsafePath = errors.New("archive contains unsafe path")
)

// Extract decompresses and untars tarball into a Module. If the
// cumulative uncompressed size exceeds maxBytes, Extract returns
// ErrTooLarge. Paths containing ".." or absolute roots are rejected with
// ErrUnsafePath. Symlinks are dropped silently (they are not useful
// inputs to a textual or semantic diff).
func Extract(tarball []byte, maxBytes int64) (Module, error) {
	gz, err := gzip.NewReader(bytes.NewReader(tarball))
	if err != nil {
		return Module{}, fmt.Errorf("gzip: %w", err)
	}
	defer gz.Close()

	tr := tar.NewReader(gz)
	files := make(map[string][]byte)
	var total int64
	for {
		hdr, err := tr.Next()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return Module{}, fmt.Errorf("tar: %w", err)
		}
		switch hdr.Typeflag {
		case tar.TypeReg, tar.TypeRegA:
			if err := safePath(hdr.Name); err != nil {
				return Module{}, err
			}
			total += hdr.Size
			if total > maxBytes {
				return Module{}, ErrTooLarge
			}
			body := make([]byte, hdr.Size)
			if _, err := io.ReadFull(tr, body); err != nil {
				return Module{}, fmt.Errorf("reading %s: %w", hdr.Name, err)
			}
			files[hdr.Name] = body
		default:
			// Directories, symlinks, etc. ignored.
		}
	}
	return Module{Files: files}, nil
}

func safePath(name string) error {
	if name == "" {
		return fmt.Errorf("%w: empty name", ErrUnsafePath)
	}
	if name[0] == '/' {
		return fmt.Errorf("%w: absolute path %q", ErrUnsafePath, name)
	}
	// Reject any segment that is "..", or any path containing "..".
	for i := 0; i < len(name); i++ {
		if name[i] == '\\' {
			return fmt.Errorf("%w: backslash in path %q", ErrUnsafePath, name)
		}
	}
	if containsDotDot(name) {
		return fmt.Errorf("%w: path traversal in %q", ErrUnsafePath, name)
	}
	return nil
}

func containsDotDot(p string) bool {
	for i := 0; i < len(p); i++ {
		if p[i] != '.' {
			continue
		}
		// Check for ".." preceded by start-or-slash, followed by slash-or-end.
		if i+1 < len(p) && p[i+1] == '.' {
			startOK := i == 0 || p[i-1] == '/'
			endOK := i+2 == len(p) || p[i+2] == '/'
			if startOK && endOK {
				return true
			}
		}
	}
	return false
}
```

- [ ] **Step 4: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/archive/... -v`
Expected: All PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/archive/ && git commit -m "feat(birdseye): tar.gz extraction with happy-path coverage"
```

## Notes & Style

- **`tar.TypeRegA` is deprecated** as of Go 1.13 but still exists. The plan lists it explicitly to handle older tarballs. Keep the constant in the switch case; if the linter complains, it's fine to add `//nolint:staticcheck` as a comment. If Go 1.25 has removed it, fall back to `case tar.TypeReg:`.
- Strict TDD: do not implement before tests are written and shown to fail.
- The helper `buildTarGz` is exported within the test file (lowercase `buildTarGz`, so package-private). Task 5 will reuse it.
- Do NOT add tests for zip-slip / size cap in this task. Task 5 handles those.

## Code Organization

Single file `extract.go` for now. Don't split into `extract.go` + `safepath.go`.

## Self-Review Checklist

- [ ] All 3 tests pass (`TestExtractHappyPath`, `TestExtractEmptyArchive`, `TestExtractMalformedGzip`)
- [ ] `go vet ./...` succeeds
- [ ] `Module.Files` is correctly populated with the expected bodies
- [ ] Empty archive → empty `Files` map (no nil pointer)
- [ ] Malformed gzip → non-nil error
- [ ] Commit message exactly: `feat(birdseye): tar.gz extraction with happy-path coverage`
- [ ] Only `apps/birdseye/internal/archive/` files staged

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Implementation summary
- Test count + results
- Whether `tar.TypeRegA` was kept or removed (and why)
- Files committed
- Commit SHA
- Self-review findings
- Anything unexpected


- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll implement Task 4 following strict TDD. Let me start by checking the existing structure.


Bash

List existing internal packages


Error: Exit code 2
lsd: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/: No such file or directory (os error 2).


List birdseye app structure


drwxr-xr-x steve.briggs staff 256 B  Tue May 12 18:19:50 2026 ..
.rw-r--r-- steve.briggs staff 226 B  Tue May 12 18:19:59 2026 Makefile
.rw-r--r-- steve.briggs staff   6 B  Tue May 12 18:20:01 2026 .gitignore
.rw-r--r-- steve.briggs staff 602 B  Tue May 12 18:20:06 2026 main.go
drwxr-xr-x steve.briggs staff 256 B  Tue May 12 21:18:52 2026 .
.rw-r--r-- steve.briggs staff 2.4 KB Tue May 12 22:56:51 2026 go.mod
.rw-r--r-- steve.briggs staff 7.4 KB Tue May 12 22:56:51 2026 go.sum
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:04:43 2026 cmd


Good - no `internal/` directory yet. Let me check the go.mod to confirm module name, then create the test file first.


1	module github.com/immuta/operations/apps/birdseye
2
3	go 1.25.5
4
5	require github.com/spf13/cobra v1.9.1
6
7	require (
8		github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
9		github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
10		github.com/aws/aws-sdk-go-v2/config v1.32.9 // indirect
11		github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect
12		github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
13		github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
14		github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
15		github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
16		github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
17		github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
18		github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
19		github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
20		github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
21		github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect
22		github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
23		github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
24		github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
25		github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
26		github.com/aws/smithy-go v1.24.1 // indirect
27		github.com/davecgh/go-spew v1.1.1 // indirect
28		github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a // indirect
29		github.com/inconshreveable/mousetrap v1.1.0 // indirect
30		github.com/labstack/echo/v4 v4.15.0 // indirect
31		github.com/labstack/gommon v0.4.2 // indirect
32		github.com/mattn/go-colorable v0.1.14 // indirect
33		github.com/mattn/go-isatty v0.0.20 // indirect
34		github.com/pmezard/go-difflib v1.0.0 // indirect
35		github.com/spf13/pflag v1.0.6 // indirect
36		github.com/stretchr/objx v0.5.3 // indirect
37		github.com/stretchr/testify v1.11.1 // indirect
38		github.com/valyala/bytebufferpool v1.0.0 // indirect
39		github.com/valyala/fasttemplate v1.2.2 // indirect
40		golang.org/x/crypto v0.48.0 // indirect
41		golang.org/x/net v0.50.0 // indirect
42		golang.org/x/sys v0.41.0 // indirect
43		golang.org/x/text v0.34.0 // indirect
44		golang.org/x/time v0.14.0 // indirect
45		gopkg.in/yaml.v3 v3.0.1 // indirect
46	)
47
48	replace github.com/immuta/operations/services/crow => ../../services/crow
49
50	replace github.com/immuta/operations/libs/clog => ../../libs/clog
51


Go 1.25.5 - good to know. Now let me check if `tar.TypeRegA` still exists in this Go version.


go doc archive/tar TypeRegA 2>&1 || echo "TypeRegA not found"

Check if tar.TypeRegA exists


package tar // import "archive/tar"

const (
	// Type '0' indicates a regular file.
	TypeReg = '0'

	// Deprecated: Use TypeReg instead.
	TypeRegA = '\x00'

	// Type '1' to '6' are header-only flags and may not have a data body.
	TypeLink    = '1' // Hard link
	TypeSymlink = '2' // Symbolic link
	TypeChar    = '3' // Character device node
	TypeBlock   = '4' // Block device node
	TypeDir     = '5' // Directory
	TypeFifo    = '6' // FIFO node

	// Type '7' is reserved.
	TypeCont = '7'

	// Type 'x' is used by the PAX format to store key-value records that
	// are only relevant to the next file.
	// This package transparently handles these types.
	TypeXHeader = 'x'

	// Type 'g' is used by the PAX format to store key-value records that
	// are relevant to all subsequent files.
	// This package only supports parsing and composing such headers,
	// but does not currently support persisting the global state across files.
	TypeXGlobalHeader = 'g'

	// Type 'S' indicates a sparse file in the GNU format.
	TypeGNUSparse = 'S'

	// Types 'L' and 'K' are used by the GNU format for a meta file
	// used to store the path or link name for the next file.
	// This package transparently handles these types.
	TypeGNULongName = 'L'
	TypeGNULongLink = 'K'
)
    Type flags for Header.Typeflag.


`tar.TypeRegA` is still present (deprecated but available). Now let me proceed with strict TDD - write the test file first.

**Step 1: Create the test file**


Create archive package directory


(Bash completed with no output)


Write

package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"testing"
)

// buildTarGz produces a gzipped tar with the given files in memory.
// Used by archive, diff, and registry tests.
func buildTarGz(t *testing.T, files map[string]string) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for name, body := range files {
		hdr := &tar.Header{
			Name: name,
			Mode: 0o644,
			Size: int64(len(body)),
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatalf("WriteHeader %s: %v", name, err)
		}
		if _, err := tw.Write([]byte(body)); err != nil {
			t.Fatalf("Write %s: %v", name, err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gz.Close(); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

func TestExtractHappyPath(t *testing.T) {
	in := map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"x\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
		"README.md":    "hello\n",
	}
	tgz := buildTarGz(t, in)

	mod, err := Extract(tgz, 1<<20)
	if err != nil {
		t.Fatalf("Extract: %v", err)
	}
	if len(mod.Files) != len(in) {
		t.Fatalf("got %d files, want %d", len(mod.Files), len(in))
	}
	for name, want := range in {
		got, ok := mod.Files[name]
		if !ok {
			t.Errorf("missing file: %s", name)
			continue
		}
		if string(got) != want {
			t.Errorf("%s body = %q, want %q", name, got, want)
		}
	}
}

func TestExtractEmptyArchive(t *testing.T) {
	tgz := buildTarGz(t, nil)
	mod, err := Extract(tgz, 1<<20)
	if err != nil {
		t.Fatalf("Extract empty: %v", err)
	}
	if len(mod.Files) != 0 {
		t.Errorf("expected 0 files, got %d", len(mod.Files))
	}
}

func TestExtractMalformedGzip(t *testing.T) {
	_, err := Extract([]byte("not a gzip"), 1<<20)
	if err == nil {
		t.Fatal("expected error on malformed input")
	}
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract_test.go (file state is current in your context — no need to Read it back)


**Step 2: Run tests — expect FAIL (undefined: Extract)**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/archive/... 2>&1

Run tests expecting compile failure


Error: Exit code 1
# github.com/immuta/operations/apps/birdseye/internal/archive [github.com/immuta/operations/apps/birdseye/internal/archive.test]
internal/archive/extract_test.go:47:14: undefined: Extract
internal/archive/extract_test.go:68:14: undefined: Extract
internal/archive/extract_test.go:78:12: undefined: Extract
FAIL	github.com/immuta/operations/apps/birdseye/internal/archive [build failed]
FAIL


Tests fail as expected with `undefined: Extract`. Now implementing.

**Step 3: Implement extract.go**


Write

// Package archive extracts gzipped tar archives from Crow into an
// in-memory file map. Diff and TUI layers consume the resulting Module
// type and never touch the network themselves.
package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
)

// Module is the in-memory representation of an extracted module tarball.
// Keys are forward-slash relative paths. Symlinks are not stored.
type Module struct {
	Files map[string][]byte
}

var (
	ErrTooLarge   = errors.New("archive exceeds size cap")
	ErrUnsafePath = errors.New("archive contains unsafe path")
)

// Extract decompresses and untars tarball into a Module. If the
// cumulative uncompressed size exceeds maxBytes, Extract returns
// ErrTooLarge. Paths containing ".." or absolute roots are rejected with
// ErrUnsafePath. Symlinks are dropped silently (they are not useful
// inputs to a textual or semantic diff).
func Extract(tarball []byte, maxBytes int64) (Module, error) {
	gz, err := gzip.NewReader(bytes.NewReader(tarball))
	if err != nil {
		return Module{}, fmt.Errorf("gzip: %w", err)
	}
	defer gz.Close()

	tr := tar.NewReader(gz)
	files := make(map[string][]byte)
	var total int64
	for {
		hdr, err := tr.Next()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return Module{}, fmt.Errorf("tar: %w", err)
		}
		switch hdr.Typeflag {
		case tar.TypeReg, tar.TypeRegA: //nolint:staticcheck
			if err := safePath(hdr.Name); err != nil {
				return Module{}, err
			}
			total += hdr.Size
			if total > maxBytes {
				return Module{}, ErrTooLarge
			}
			body := make([]byte, hdr.Size)
			if _, err := io.ReadFull(tr, body); err != nil {
				return Module{}, fmt.Errorf("reading %s: %w", hdr.Name, err)
			}
			files[hdr.Name] = body
		default:
			// Directories, symlinks, etc. ignored.
		}
	}
	return Module{Files: files}, nil
}

func safePath(name string) error {
	if name == "" {
		return fmt.Errorf("%w: empty name", ErrUnsafePath)
	}
	if name[0] == '/' {
		return fmt.Errorf("%w: absolute path %q", ErrUnsafePath, name)
	}
	// Reject any segment that is "..", or any path containing "..".
	for i := 0; i < len(name); i++ {
		if name[i] == '\\' {
			return fmt.Errorf("%w: backslash in path %q", ErrUnsafePath, name)
		}
	}
	if containsDotDot(name) {
		return fmt.Errorf("%w: path traversal in %q", ErrUnsafePath, name)
	}
	return nil
}

func containsDotDot(p string) bool {
	for i := 0; i < len(p); i++ {
		if p[i] != '.' {
			continue
		}
		// Check for ".." preceded by start-or-slash, followed by slash-or-end.
		if i+1 < len(p) && p[i+1] == '.' {
			startOK := i == 0 || p[i-1] == '/'
			endOK := i+2 == len(p) || p[i+2] == '/'
			if startOK && endOK {
				return true
			}
		}
	}
	return false
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract.go (file state is current in your context — no need to Read it back)


**Step 4: Run tests with race detector — expect PASS**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/archive/... -v 2>&1

Run tests with race detector expecting all pass


=== RUN   TestExtractHappyPath
--- PASS: TestExtractHappyPath (0.00s)
=== RUN   TestExtractEmptyArchive
--- PASS: TestExtractEmptyArchive (0.00s)
=== RUN   TestExtractMalformedGzip
--- PASS: TestExtractMalformedGzip (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/archive	1.560s


All 3 tests pass. Run `go vet` before committing.


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go vet ./... 2>&1

Run go vet on all packages


`go vet` clean. **Step 5: Commit.**


git add apps/birdseye/internal/archive/ && git commit -m "feat(birdseye): tar.gz extraction with happy-path coverage"

Stage and commit archive package


[worktree-birdseye 67c8b68] feat(birdseye): tar.gz extraction with happy-path coverage
 2 files changed, 186 insertions(+)
 create mode 100644 apps/birdseye/internal/archive/extract.go
 create mode 100644 apps/birdseye/internal/archive/extract_test.go


---

**Status:** DONE

**Implementation summary:** Created `apps/birdseye/internal/archive/` package with two files implementing tar.gz extraction into an in-memory `Module` type. Followed strict TDD order: test file written and confirmed failing before implementation was written.

**Test count + results:** 3 tests, all PASS with `-race`:
- `TestExtractHappyPath` — 3-file archive correctly extracted
- `TestExtractEmptyArchive` — empty archive yields empty `Files` map (no nil)
- `TestExtractMalformedGzip` — non-gzip input returns non-nil error

**`tar.TypeRegA` status:** Kept. It is still present in Go 1.25.5 (marked deprecated since Go 1.13, not removed). Added `//nolint:staticcheck` comment per plan guidance.

**Files committed:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract_test.go`

**Commit SHA:** `67c8b68`

**Self-review findings:** All checklist items satisfied. `go vet ./...` clean. No unexpected issues encountered. Task 5's hardening tests (zip-slip, size cap, symlinks) will reuse `buildTarGz` from this package's test file.