diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..794109d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + time: "03:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "03:15" + timezone: Etc/UTC + open-pull-requests-limit: 3 + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a7037..a754704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,94 +4,121 @@ on: push: branches: [main] pull_request: - branches: [main] + schedule: + - cron: '17 4 * * 1' workflow_dispatch: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - run: test -z "$(gofmt -l .)" - run: go vet ./... test: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum - - run: go test ./... -timeout 15m + cache: false + - run: go test ./... -covermode=atomic -coverprofile=coverage.out -timeout 15m + - run: go tool cover -func=coverage.out + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unit-coverage + path: coverage.out + if-no-files-found: error race: runs-on: ubuntu-latest + timeout-minutes: 25 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - run: go test -race ./... -timeout 20m build: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - run: go build ./... integration: runs-on: ubuntu-latest + timeout-minutes: 25 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - run: go test -tags=integration ./test/integration/... -timeout 20m integration-race: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - run: go test -race -tags=integration ./test/integration/... -timeout 25m release-dry-run: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - name: Run release packaging dry-run run: | - chmod +x scripts/build-release-artifacts.sh - VERSION=0.0.0-dry-run COMMIT=DRY-RUN BUILD_DATE=$(date -u +%Y-%m-%d) \ - ./scripts/build-release-artifacts.sh ./dist + SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA") + export SOURCE_DATE_EPOCH + BUILD_DATE=$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d) + VERSION=0.0.0-dry-run COMMIT=DRY-RUN BUILD_DATE="$BUILD_DATE" \ + bash scripts/verify-reproducible-release.sh ./dist - name: Verify archives run: | set -euo pipefail @@ -111,7 +138,58 @@ jobs: test -f checksums.txt || { echo "checksums.txt missing"; exit 1; } sha256sum -c checksums.txt || { echo "Checksum verification failed"; exit 1; } echo "All archives valid" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-dry-run path: dist/* + if-no-files-found: error + + platform-smoke: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: false + - run: go test ./... -timeout 15m + - run: go build ./cmd/taskcapsule + + govulncheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: false + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... + + codeql: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + packages: read + security-events: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: github/codeql-action/init@bce182f857edf1feab116e9795a3393d21977282 # v4 + with: + languages: go + - uses: github/codeql-action/analyze@bce182f857edf1feab116e9795a3393d21977282 # v4 + with: + category: /language:go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e12dddc..9056bd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,19 +11,25 @@ on: required: true default: 'true' +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + permissions: contents: read jobs: validate-and-test: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - name: Validate tag or branch run: | @@ -59,36 +65,56 @@ jobs: needs: [validate-and-test] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - name: Build and package all targets run: | - chmod +x scripts/build-release-artifacts.sh + SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA") + export SOURCE_DATE_EPOCH VERSION="${GITHUB_REF_NAME#v}" \ COMMIT="$GITHUB_SHA" \ - BUILD_DATE="$(date -u +%Y-%m-%d)" \ - ./scripts/build-release-artifacts.sh ./dist + BUILD_DATE="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d)" \ + bash scripts/verify-reproducible-release.sh ./dist + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: . + format: cyclonedx-json + artifact-name: taskcapsule-${{ github.ref_name }}.cdx.json + output-file: dist/taskcapsule-${{ github.ref_name }}.cdx.json + upload-artifact: false - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-artifacts path: dist/* + if-no-files-found: error release: needs: [validate-and-test, build] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + timeout-minutes: 15 permissions: + actions: read + attestations: write + artifact-metadata: write contents: write + id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts merge-multiple: true @@ -99,12 +125,20 @@ jobs: sha256sum *.tar.gz *.zip > checksums.txt ls -la + - name: Attest archives and SBOM + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: | + artifacts/*.tar.gz + artifacts/*.zip + sbom-path: artifacts/taskcapsule-${{ github.ref_name }}.cdx.json + - name: Create Release env: GH_TOKEN: ${{ github.token }} run: | gh release create "$GITHUB_REF_NAME" \ - artifacts/*.tar.gz artifacts/*.zip artifacts/checksums.txt \ + artifacts/*.tar.gz artifacts/*.zip artifacts/checksums.txt artifacts/*.cdx.json \ --repo "$GITHUB_REPOSITORY" \ --title "TaskCapsule $GITHUB_REF_NAME" \ --notes "See the [CHANGELOG](https://github.com/$GITHUB_REPOSITORY/blob/main/CHANGELOG.md) for details." \ @@ -115,21 +149,24 @@ jobs: needs: [validate-and-test] if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version-file: go.mod - cache: true - cache-dependency-path: go.sum + cache: false - name: Package all targets run: | - chmod +x scripts/build-release-artifacts.sh + SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA") + export SOURCE_DATE_EPOCH VERSION=0.0.0-dry-run \ COMMIT=DRY-RUN \ - BUILD_DATE=$(date -u +%Y-%m-%d) \ - ./scripts/build-release-artifacts.sh ./dist + BUILD_DATE=$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d) \ + bash scripts/verify-reproducible-release.sh ./dist - name: Verify all 5 archives exist run: | @@ -172,7 +209,8 @@ jobs: sha256sum -c checksums.txt || { echo "checksum verification failed"; exit 1; } echo "All checksums valid" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-dry-run path: dist/* + if-no-files-found: error diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..7c56d26 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,40 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '31 4 * * 1' + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + security-events: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Run Scorecard + uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Upload SARIF artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + if-no-files-found: error + - name: Upload SARIF to code scanning + uses: github/codeql-action/upload-sarif@bce182f857edf1feab116e9795a3393d21977282 # v4 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 833395a..59478f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -contributions.md -bin/\n*.exe\n*.test\n*.out +bin/ +dist/ +*.exe +*.test +*.out +coverage.out diff --git a/CHANGELOG.md b/CHANGELOG.md index 5278d2e..713a8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog -## [1.1.0] - 2026-07-26 +## [Unreleased] + +### Security + +- Raise the minimum toolchain to Go 1.25 after vulnerability analysis found reachable issues in Go 1.24's standard library. +- Reject unsafe capsule, service, check, repository, and state path components. +- Validate loaded state and restrict recursive cleanup to the managed worktree root. +- Prevent working-directory symlinks from escaping a capsule worktree. +- Stop forwarding unlisted parent environment variables to child services. +- Store state, logs, checks, and handoffs with owner-only permissions. +- Replace state through unique synchronized temporary files. + +### Added + +- CodeQL, `govulncheck`, OpenSSF Scorecard, dependency updates, and cross-platform smoke gates. +- Deterministic release verification, CycloneDX SBOM generation, and artifact attestations. +- Regression coverage for path traversal, environment isolation, and symlink escape. + +### Fixed + +- Apply configured service environment values, inherited variables, dynamic ports, and working directories. +- Close parent service-log descriptors after process startup. +- Propagate check-log and state persistence failures. + +## [0.1.2] - 2026-07-24 + +### Fixed + +- Scoped `doctor` branch checks to capsules belonging to the current repository. +- Prevented false branch warnings for capsules from other repositories. +- Normalized repository path separators when computing fallback repository IDs on Windows. +- Added isolated regression tests for cross-repository doctor diagnostics. + +## [0.1.1] - 2026-07-23 + +### Fixed + +- Non-destructive process existence checks using signal 0. +- Deterministic process tests without external `sleep` or `sh` dependencies. +- Strict release archive and checksum validation. +- Shell completion command coverage and deduplication. +- Bounded service log reading. + +### Changed + +- CI and release workflows use the Go version declared in `go.mod`. +- Release packaging uses the shared `scripts/build-release-artifacts.sh`. +- Removed the unused duplicate `internal/doctor` package. + +## [0.1.0] - 2026-07-14 + ### Added -- Quick start section + +- Capsule initialization, lifecycle, status, notes, checks, logs, handoff, deletion, diagnostics, and version commands. +- Git worktree creation and removal. +- Unix process-group management. +- Dynamic port allocation. +- Process, TCP, and HTTP health checks. +- Atomic state storage and per-capsule locking. +- Pattern-based handoff redaction. +- Linux and macOS support with experimental Windows support. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f486a4..5c3ceee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,12 +11,24 @@ Thank you for considering contributing to TaskCapsule. ## Development Setup -- Go 1.24+ -- Run `go test ./...` before submitting. -- Ensure `golangci-lint` passes. +- Go 1.25+ +- Git available on `PATH` +- Run `gofmt`, `go vet`, unit tests, and integration tests before submitting. + +```bash +test -z "$(gofmt -l .)" +go vet ./... +go test ./... -timeout 15m +go test -tags=integration ./test/integration/... -timeout 20m +``` ## Code Guidelines -- Follow standard Go conventions (`gofmt`, `golint`). +- Follow standard Go conventions and keep `gofmt` clean. - Write tests for new functionality. - Keep PRs focused on a single concern. +- Do not weaken filesystem boundaries, lock semantics, state validation, or secret-handling guarantees without updating the threat model and adding adversarial tests. + +## Pull Requests + +All required GitHub checks must pass. Security-sensitive changes should explain their trust boundary, negative tests, failure behavior, and platform impact. Do not include credentials, private logs, or unredacted handoff data in issues or pull requests. diff --git a/README.md b/README.md index 8d0ecfe..28087a5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,134 @@ # TaskCapsule -Pause and resume coding tasks -## License\n\nApache 2.0 +TaskCapsule isolates coding tasks in Git worktrees, manages their local services, and preserves enough state to pause, resume, validate, and hand work off without losing context. -## Quick Start +It is designed for developers and coding agents that work on several branches concurrently. TaskCapsule is a local orchestration tool; it is not a remote execution service, container sandbox, or security boundary for untrusted repositories. + +## Status + +The latest supported release is the newest version listed on the [GitHub Releases](https://github.com/vtino17/taskcapsule/releases) page. Linux and macOS are supported. Windows process management remains experimental and is tested separately from the Unix process-group implementation. + +Release candidates must pass formatting, vet, unit, race, integration, cross-platform build, CodeQL, `govulncheck`, reproducible packaging, SBOM, and provenance gates. Passing those gates reduces known risk but does not guarantee the absence of defects. + +## Install + +TaskCapsule requires Git and Go 1.25 or newer. Use a currently supported Go patch release; older standard-library builds may contain known vulnerabilities. ```bash -# Install go install github.com/vtino17/taskcapsule@latest +taskcapsule version +``` + +You can also download a checksum-listed archive from [GitHub Releases](https://github.com/vtino17/taskcapsule/releases). Published release archives include Linux amd64/arm64, macOS amd64/arm64, and Windows amd64 binaries. + +## Quick start + +Run these commands from a Git repository: -# Start a task -taskcapsule start my-feature +```bash +taskcapsule init +taskcapsule start my-feature --no-services +taskcapsule note my-feature "Parser implemented; add malformed-input tests next." +taskcapsule check my-feature -- go test ./... +taskcapsule pause my-feature +taskcapsule resume my-feature +taskcapsule handoff my-feature +``` + +Each capsule receives a managed worktree below `~/.taskcapsule/worktrees`. State, checks, and service logs are stored below `~/.taskcapsule`; Unix builds apply restrictive modes, while Windows storage inherits the current account's ACLs. + +## Configuration + +`taskcapsule init` creates `.taskcapsule.json`. Commands are arrays and are executed directly without shell parsing. + +```json +{ + "version": 1, + "defaults": { + "baseBranch": "main", + "branchPrefix": "task/", + "gracefulShutdownSeconds": 5, + "healthTimeoutSeconds": 30 + }, + "setup": [ + { "command": ["go", "mod", "download"] } + ], + "services": { + "api": { + "command": ["go", "run", "./cmd/api"], + "workingDirectory": ".", + "environment": { + "APP_ENV": "development", + "PORT": "${PORT:api}" + }, + "inheritEnvironment": ["DATABASE_URL"], + "health": { + "type": "http", + "url": "http://127.0.0.1:${PORT:api}/health", + "expectedStatus": 200, + "timeoutSeconds": 30 + } + } + }, + "checks": { + "test": { "command": ["go", "test", "./..."] } + } +} ``` + +Static, non-secret environment values belong in `environment`. Put only variable names in `inheritEnvironment`; their values are read from the TaskCapsule process environment and are not persisted to capsule state. Service working directories must remain inside the managed worktree, including after symlink resolution. + +See [configuration](docs/configuration.md) for the complete schema. + +## Commands + +| Command | Purpose | +| --- | --- | +| `init` | Create `.taskcapsule.json` | +| `start` | Create a branch/worktree and start configured services | +| `pause` / `resume` | Stop and restart capsule services | +| `status` / `list` / `where` | Inspect capsule state and continuation context | +| `note` | Store a concise continuation note | +| `check` | Run an explicit validation command in the worktree | +| `logs` | Read bounded service-log tails | +| `handoff` | Generate a redacted Markdown handoff | +| `doctor` | Detect stale locks and inconsistent local state | +| `delete` | Remove a managed worktree after safety checks | +| `completion` | Generate shell completion | + +Run `taskcapsule --help` for command syntax. + +## Safety model + +- Capsule, service, and check identifiers are validated before they can form filesystem paths. +- Destructive cleanup is restricted to the configured TaskCapsule worktree root. +- Loaded state must match the active repository and capsule before it is used. +- Service working-directory symlinks cannot escape the managed worktree. +- Child services receive a small baseline environment plus explicitly configured or inherited variables, rather than every parent secret. +- On Unix, state directories use mode `0700` and state, logs, check output, and handoff files use mode `0600`. Windows storage inherits the current account's ACLs; Windows support remains experimental. +- Operations use per-capsule exclusive locks and atomic state replacement. +- HTTP health checks use bounded timeouts. A configured HTTP health URL can make an outbound request, so configuration must be trusted. + +TaskCapsule intentionally executes commands declared in the repository configuration. Review `.taskcapsule.json` before using TaskCapsule in an unfamiliar repository. + +See [security architecture](docs/security.md), [architecture](docs/architecture.md), and the [security policy](SECURITY.md). + +## Verification + +```bash +go test ./... -timeout 15m +go test -race ./... -timeout 20m +go test -tags=integration ./test/integration/... -timeout 20m +go vet ./... +bash scripts/verify-reproducible-release.sh ./dist +``` + +The race detector may be unavailable on constrained kernels. GitHub CI runs the authoritative race gate on a supported Linux runner. + +## Contributing and support + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. Use GitHub Issues for reproducible bugs and feature discussions. Report suspected vulnerabilities privately as described in [SECURITY.md](SECURITY.md). + +## License + +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 4b970f8..f8af70b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,10 +22,21 @@ Do not open a public issue for security vulnerabilities. | latest | Yes | | older | No | +Only the newest published release receives security fixes. The default branch and pull-request builds are development candidates, not supported releases. + ## Known Security Properties -- TaskCapsule never persists environment variable values +- Values named in `inheritEnvironment` are read at process start and are not persisted to capsule state +- Static values placed directly in `.taskcapsule.json` are part of the repository configuration and must not contain secrets - Handoff reports redact likely secrets (API keys, tokens, passwords) -- State files use restrictive permissions (0600) +- On Unix, state directories use mode `0700` and state, log, check, and handoff files use mode `0600`; Windows storage inherits the current account's ACLs +- Capsule identifiers and loaded state are validated before filesystem operations +- Recursive worktree cleanup is restricted to the managed TaskCapsule worktree root - No network services listen by default -- No external API calls occur +- HTTP health checks can make requests to URLs explicitly provided by trusted repository configuration + +## Threat Model + +TaskCapsule assumes the current operating-system account and repository configuration are trusted. It executes configured commands and is not a sandbox for hostile code. The project protects against accidental path escape, malformed or inconsistent state, secret persistence through inherited environment values, concurrent lifecycle operations, and unsafe deletion boundaries. + +An attacker who already controls the user's account, TaskCapsule binary, Git executable, or repository configuration can execute code with that user's permissions and is outside this threat model. diff --git a/docs/audit.md b/docs/audit.md index 73f6924..55758d9 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -1,92 +1,53 @@ -# TaskCapsule Audit - -## Declared Go Version - -`go.mod`: `go 1.24` - -## Go Version in CI - -Every workflow job uses `go-version-file: go.mod` (resolves to `go 1.24`). - -## CI Validation - -Final PR commit: `f3b0f7d89edc6604c0ba8a77902b9cfde191c807` -Workflow run: [30006669553](https://github.com/vtino17/taskcapsule/actions/runs/30006669553) - -| Job | Conclusion | -|-----|-----------| -| lint | CI VERIFIED / SUCCESS | -| test | CI VERIFIED / SUCCESS | -| race | CI VERIFIED / SUCCESS | -| build | CI VERIFIED / SUCCESS | -| integration | CI VERIFIED / SUCCESS | -| integration-race | CI VERIFIED / SUCCESS | -| release-dry-run | CI VERIFIED / SUCCESS | - -## Local Validation - -| Command | Status | -|---------|--------| -| `go build ./...` | VERIFIED | -| `go test ./...` | VERIFIED | -| `go vet ./...` | VERIFIED | -| `gofmt -l .` | VERIFIED | -| `go mod verify` | VERIFIED | -| Race tests (local) | BLOCKED (CGO unavailable) | - -## Package Test Coverage - -All 13 packages have tests: - -| Package | Tests | Status | -|---------|-------|--------| -| `app` | Yes (exit codes, state dir, log reader) | VERIFIED | -| `capsule` | Yes (model, validation, state machine) | VERIFIED | -| `checks` | Yes (success, failure, missing exec) | VERIFIED | -| `cli` | Yes (completion, command dispatch) | VERIFIED | -| `config` | Yes (load, template, validate) | VERIFIED | -| `git` | Yes (branch, repo ID, worktree) | LOCALLY VERIFIED | -| `health` | Yes (HTTP, TCP, timeout, StatusError) | VERIFIED | -| `lock` | Yes (file lock, isAlive) | VERIFIED | -| `ports` | Yes (allocator) | VERIFIED | -| `process` | Yes (start, stop, group, helper pattern) | VERIFIED | -| `report` | Yes (handoff, redact) | VERIFIED | -| `state` | Yes (store, atomic writes) | VERIFIED | -| `version` | Yes (build info) | VERIFIED | - -Note: The duplicate `internal/doctor` package was removed. It was dead code (no references to it existed anywhere in the codebase). All doctor functionality is provided by `internal/app.Doctor()`. - -## Shell Completion - -| Shell | Generation | Syntax Check | Status | -|-------|-----------|-------------|--------| -| bash | VERIFIED | NOT VERIFIED (bash -n unavailable) | LOCALLY VERIFIED | -| zsh | VERIFIED | NOT VERIFIED | LOCALLY VERIFIED | -| fish | VERIFIED | NOT VERIFIED | LOCALLY VERIFIED | -| powershell | VERIFIED | CI VERIFIED | VERIFIED | - -## Log Safety - -- Default: 200 lines / 256 KiB tail -- Configurable via `--lines N` flag -- Large files: tail from byte limit + truncation notice - -## Platform Support - -| Feature | Linux | macOS | Windows | -|---------|-------|-------|---------| -| Git worktree | CI VERIFIED | CI VERIFIED | NOT VERIFIED | -| Process groups | CI VERIFIED | CI VERIFIED | EXPERIMENTAL | -| PID management | CI VERIFIED | CI VERIFIED | PARTIAL | -| Port allocation | CI VERIFIED | CI VERIFIED | CI VERIFIED | -| Health checks | CI VERIFIED | CI VERIFIED | CI VERIFIED | -| Secret redaction | CI VERIFIED | CI VERIFIED | CI VERIFIED | - -## Tag-Triggered Release Publication - -NOT VERIFIED. No release tag has been created. - -## Known Limitations - -- Windows process management is EXPERIMENTAL -- Docker Compose integration: NOT IMPLEMENTED +# Assurance record + +This document describes reproducible evidence for TaskCapsule. It is not an external certification and does not guarantee defect-free operation. + +## Required pull-request gates + +| Control | Evidence | +| --- | --- | +| Formatting and static analysis | `gofmt`, `go vet` | +| Unit behavior | `go test ./...` plus coverage artifact | +| Data-race detection | `go test -race ./...` on a supported GitHub Linux runner | +| Lifecycle behavior | tagged integration suite, with a separate race run | +| Platform compatibility | macOS and Windows smoke jobs plus release cross-builds | +| Known Go vulnerabilities | pinned `govulncheck` module invocation | +| Static security analysis | CodeQL for Go | +| Workflow and repository posture | OpenSSF Scorecard SARIF | +| Release integrity | two-build reproducibility comparison and SHA-256 checksums | +| Release inventory | CycloneDX SBOM | +| Provenance | GitHub artifact attestation using OIDC | + +## Local candidate validation + +The hardening candidate is evaluated with the checksum-verified official Go 1.25.12 Linux arm64 toolchain. Go 1.24 was rejected after `govulncheck` identified reachable standard-library vulnerabilities fixed in supported Go 1.25 patches. + +| Command | Result | +| --- | --- | +| `go test ./... -count=1 -timeout 15m` | Passed | +| `go test -tags=integration ./test/integration/... -timeout 20m` | Passed | +| `go vet ./...` | Passed | +| `go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...` | Passed: no vulnerabilities found | +| `bash scripts/verify-reproducible-release.sh ` | Passed: five target archives matched byte-for-byte and all checksums verified | +| `go test -race ./...` | Environment blocked: local kernel exposes an unsupported ThreadSanitizer VMA range | + +The authoritative race result is therefore the protected GitHub CI job, which runs on a supported hosted runner. + +## Security controls under test + +- unsafe capsule, service, check, repository, and state path inputs fail closed; +- loaded state cannot redirect worktree cleanup outside the managed root; +- service working-directory symlinks cannot escape a worktree; +- unlisted parent environment values are not passed to child services; +- state replacement uses a unique same-directory temporary file, synchronization, and rename; +- on Unix, state directories are mode `0700` and state, service logs, check logs, and handoff files are mode `0600`; Windows storage inherits the current account's ACLs; +- action dependencies are pinned to immutable commits; +- published archives are compared byte-for-byte across two builds before release. + +## Known limitations + +- Windows process lifecycle management is experimental. +- Local ephemeral-port allocation has an unavoidable bind gap between reservation and service startup; services must handle a bind failure, and TaskCapsule rolls back partial startup. +- Commands and health destinations from `.taskcapsule.json` are trusted input and execute with the current user's authority. +- Redaction is pattern-based defense in depth and requires human inspection before a handoff crosses trust boundaries. +- A tag-triggered release with the new SBOM and attestation path is not considered verified until its GitHub run succeeds. diff --git a/docs/configuration.md b/docs/configuration.md index e69e3f7..bf1a8b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,8 +21,8 @@ File: `.taskcapsule.json` |-------|------|-------------| | `command` | array | Command to run (required) | | `workingDirectory` | string | Working directory relative to worktree | -| `environment` | object | Environment variables with `${PORT:name}` support | -| `inheritEnvironment` | array | Environment variable names to inherit | +| `environment` | object | Static, non-secret environment values with `${PORT:name}` support | +| `inheritEnvironment` | array | Parent environment variable names to copy without persisting their values | | `health` | object | Health check configuration | ## Health check types @@ -34,4 +34,8 @@ File: `.taskcapsule.json` ## Command security -Commands must be arrays, not shell strings. This prevents injection. +Commands must be non-empty arrays and are passed directly to the operating system without shell-string interpolation. They still execute with the current user's permissions, so repository configuration must be trusted. + +Service and check names are bounded identifiers containing letters, digits, `_`, or `-`. Environment variable names use the portable `[A-Za-z_][A-Za-z0-9_]*` form. Working directories must be relative and remain inside the capsule worktree after symlink resolution. + +Do not store secrets in `environment`. List their variable names in `inheritEnvironment`; TaskCapsule reads those values at service start and does not write them to capsule state. diff --git a/docs/security.md b/docs/security.md index d6e54ff..19e8d41 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,30 +1,42 @@ -# Security +# Security architecture -## Secret handling +## Trust boundary -TaskCapsule may store environment variable names in configuration, but NEVER their values. +TaskCapsule is a local orchestration tool. The operating-system account, Git executable, TaskCapsule binary, and `.taskcapsule.json` configuration are trusted. Commands in the configuration are intentionally executed with the current user's permissions; TaskCapsule is not a sandbox for untrusted repositories. -When starting services: -1. Values are read from the TaskCapsule process environment. -2. Passed to child processes. -3. NOT written to capsule state. -4. NOT displayed in terminal output. -5. NOT included in handoff reports. +## Filesystem safety -## Redaction +- Capsule, service, and check names are restricted to bounded identifiers before they form paths. +- Repository IDs are fixed-length hashes. +- Loaded state must match the requested capsule and active repository. +- A loaded worktree path must remain below the configured TaskCapsule worktree root before it can be used or recursively removed. +- Service working directories are checked after symlink resolution and cannot escape the worktree. +- State is replaced through a same-directory temporary file, `fsync`, and rename. +- On Unix, state directories use mode `0700`; state, service logs, check logs, and handoff files use mode `0600`. +- Windows does not enforce Unix mode bits. TaskCapsule storage inherits the current account's Windows ACLs, and Windows support remains experimental. + +## Command and environment handling + +Commands are arrays passed directly to `exec.Command`; TaskCapsule does not interpolate them into a shell string. This removes shell parsing but does not make a configured command trustworthy. + +Child services receive a minimal operating-system baseline environment. Static non-secret values are read from `environment`; explicitly named values are copied from the parent through `inheritEnvironment`. Unlisted parent variables are not forwarded. `${PORT:name}` placeholders are replaced after local port allocation. + +Never store credentials directly in `.taskcapsule.json`. Use `inheritEnvironment` and an external secret provider. -Handoff reports redact: -- Bearer tokens -- Authorization headers -- Passwords -- API keys -- Secrets -- Private keys +## Process and concurrency safety -## Path security +Lifecycle mutations use exclusive per-capsule lock files. Service processes are placed in process groups on Unix so rollback and pause operations can stop descendants. State transitions are persisted around service startup and shutdown, and partial startup triggers reverse-order rollback. + +Windows process management remains experimental because its process-group semantics differ from Unix. + +## Network behavior + +TaskCapsule does not listen on a network socket. TCP and HTTP health checks connect only to endpoints explicitly configured by the user. Requests use bounded timeouts. Treat configuration from an unfamiliar repository as executable code and review health-check destinations before running it. + +## Redaction -Capsule names are validated as safe slugs: alphanumeric, dashes, underscores, max 64 chars. No path separators, no `.` or `..`. +Generated handoff reports redact common bearer-token, authorization-header, password, API-key, secret, and private-key patterns. Redaction is defense in depth, not a guarantee. Review a handoff before sharing it outside its original trust boundary. -## Command security +## Supply chain -All commands are executed as arrays (not shell strings) via `exec.Command`. No shell injection possible. +CI actions are pinned to immutable commits. Pull requests run tests, the race detector, integration tests, cross-platform smoke tests, CodeQL, `govulncheck`, and OpenSSF Scorecard. Release builds create deterministic archives, SHA-256 checksums, a CycloneDX SBOM, and GitHub artifact attestations. diff --git a/docs/testing.md b/docs/testing.md index 0c1750b..c9bf0d2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,4 +33,7 @@ Integration tests use temporary directories and Git repositories to verify the f go test ./... -count=1 go test -race ./... go vet ./... +bash scripts/verify-reproducible-release.sh ./dist ``` + +GitHub CI additionally runs integration tests under the race detector, macOS and Windows smoke jobs, CodeQL, `govulncheck`, OpenSSF Scorecard, CycloneDX SBOM generation, and provenance attestation for published releases. diff --git a/go.mod b/go.mod index 311ce76..32d449a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,3 @@ module github.com/vtino17/taskcapsule -go 1.24 - +go 1.25 diff --git a/internal/app/app.go b/internal/app/app.go index e2a170d..3033a6d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -125,11 +125,18 @@ func findGitRoot() (string, error) { func getStateDir() (string, error) { if dir := os.Getenv("TASKCAPSULE_HOME"); dir != "" { - return dir, nil + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve TASKCAPSULE_HOME: %v", err) + } + if filepath.Dir(abs) == abs { + return "", fmt.Errorf("TASKCAPSULE_HOME must not be a filesystem root") + } + return abs, nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("cannot find home directory: %v", err) } - return filepath.Join(home, ".taskcapsule"), nil + return filepath.Abs(filepath.Join(home, ".taskcapsule")) } diff --git a/internal/app/capsule_test.go b/internal/app/capsule_test.go index 18da491..a585d73 100644 --- a/internal/app/capsule_test.go +++ b/internal/app/capsule_test.go @@ -3,6 +3,7 @@ package app import ( "errors" "os" + "path/filepath" "strings" "testing" ) @@ -69,15 +70,22 @@ func TestGetStateDirDefault(t *testing.T) { } func TestGetStateDirEnv(t *testing.T) { - os.Setenv("TASKCAPSULE_HOME", "/tmp/test-tc-home") - defer os.Unsetenv("TASKCAPSULE_HOME") + want := filepath.Join(t.TempDir(), "test-tc-home") + t.Setenv("TASKCAPSULE_HOME", want) dir, err := getStateDir() if err != nil { t.Fatal(err) } - if dir != "/tmp/test-tc-home" { - t.Errorf("expected /tmp/test-tc-home, got %s", dir) + if dir != want { + t.Errorf("expected %s, got %s", want, dir) + } +} + +func TestGetStateDirRejectsFilesystemRoot(t *testing.T) { + t.Setenv("TASKCAPSULE_HOME", string(os.PathSeparator)) + if _, err := getStateDir(); err == nil { + t.Fatal("filesystem root was accepted as TASKCAPSULE_HOME") } } diff --git a/internal/app/check.go b/internal/app/check.go index 99b39b6..ad4553c 100644 --- a/internal/app/check.go +++ b/internal/app/check.go @@ -13,6 +13,13 @@ import ( ) func RunCheck(name string, cmdArgs []string) (*CheckResult, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + if len(cmdArgs) == 0 || cmdArgs[0] == "" { + return nil, fmt.Errorf("check command must not be empty") + } + root, err := findGitRoot() if err != nil { return nil, err @@ -29,10 +36,13 @@ func RunCheck(name string, cmdArgs []string) (*CheckResult, error) { } defer cl.Release() - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } @@ -43,7 +53,9 @@ func RunCheck(name string, cmdArgs []string) (*CheckResult, error) { } checkDir := filepath.Join(stateBase, "capsules", repoID, name, "checks") - os.MkdirAll(checkDir, 0755) + if err := state.EnsureDir(checkDir, 0700); err != nil { + return nil, fmt.Errorf("cannot create check log directory: %v", err) + } timestamp := time.Now().UTC().Format("20060102-150405") logFile := filepath.Join(checkDir, timestamp+".log") @@ -68,7 +80,9 @@ func RunCheck(name string, cmdArgs []string) (*CheckResult, error) { logContent := fmt.Sprintf("Command: %s\nStarted: %s\nDuration: %.1fs\nExit code: %d\n\n%s", strings.Join(cmdArgs, " "), start.Format(time.RFC3339), duration, exitCode, outputBuf.String()) - os.WriteFile(logFile, []byte(logContent), 0644) + if err := os.WriteFile(logFile, []byte(logContent), 0600); err != nil { + return nil, fmt.Errorf("cannot write check log: %v", err) + } s.LastCheck = &capsule.CheckState{ Command: cmdArgs, @@ -78,7 +92,9 @@ func RunCheck(name string, cmdArgs []string) (*CheckResult, error) { LogPath: logFile, } s.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + return nil, fmt.Errorf("cannot save check result: %v", err) + } return &CheckResult{ Command: strings.Join(cmdArgs, " "), diff --git a/internal/app/delete.go b/internal/app/delete.go index ca9adfc..609e482 100644 --- a/internal/app/delete.go +++ b/internal/app/delete.go @@ -11,6 +11,10 @@ import ( ) func DeleteCapsule(name string, force bool) error { + if err := validateCapsuleName(name); err != nil { + return err + } + root, err := findGitRoot() if err != nil { return err @@ -27,10 +31,13 @@ func DeleteCapsule(name string, force bool) error { } defer cl.Release() - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return fmt.Errorf("capsule not found: %s", name) } @@ -48,7 +55,9 @@ func DeleteCapsule(name string, force bool) error { s.Status = "deleting" s.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + return fmt.Errorf("failed to persist deleting state: %v", err) + } if s.StatusPrevious() == "running" || force { for _, svcState := range s.Services { @@ -64,12 +73,14 @@ func DeleteCapsule(name string, force bool) error { return fmt.Errorf("failed to remove worktree: %v", err) } - if err := cs.Delete(repoID, name); err != nil && !force { - return fmt.Errorf("failed to remove state: %v", err) + // Clean up any leftover worktree directory + if err := os.RemoveAll(s.WorktreePath); err != nil { + return fmt.Errorf("failed to clean up worktree: %v", err) } - // Clean up any leftover worktree directory - os.RemoveAll(s.WorktreePath) + if err := cs.Delete(repoID, name); err != nil { + return fmt.Errorf("failed to remove state: %v", err) + } return nil } diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 463176c..1cf3428 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -43,19 +43,23 @@ func Doctor() ([]DoctorResult, error) { if err != nil { results = append(results, DoctorResult{OK: false, Message: "Cannot determine state directory"}) } else { - if err := os.MkdirAll(filepath.Join(stateBase, "capsules"), 0755); err != nil { + if err := state.EnsureDir(filepath.Join(stateBase, "capsules"), 0700); err != nil { results = append(results, DoctorResult{OK: false, Message: "State directory not writable"}) } else { results = append(results, DoctorResult{OK: true, Message: "State directory writable"}) } - if err := os.MkdirAll(filepath.Join(stateBase, "worktrees"), 0755); err != nil { + if err := state.EnsureDir(filepath.Join(stateBase, "worktrees"), 0700); err != nil { results = append(results, DoctorResult{OK: false, Message: "Worktree directory not writable"}) } else { results = append(results, DoctorResult{OK: true, Message: "Worktree directory writable"}) } } + if stateBase == "" { + return results, nil + } + // Check all capsules cs := state.NewStore(stateBase) capsules, err := cs.ListAll() diff --git a/internal/app/handoff.go b/internal/app/handoff.go index 8698589..c522cce 100644 --- a/internal/app/handoff.go +++ b/internal/app/handoff.go @@ -12,6 +12,10 @@ import ( ) func GenerateHandoff(name string) (string, error) { + if err := validateCapsuleName(name); err != nil { + return "", err + } + root, err := findGitRoot() if err != nil { return "", err @@ -28,10 +32,13 @@ func GenerateHandoff(name string) (string, error) { } defer cl.Release() - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return "", err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return "", fmt.Errorf("capsule not found: %s", name) } @@ -75,17 +82,23 @@ func GenerateHandoff(name string) (string, error) { }) handoffDir := filepath.Join(filepath.Dir(s.WorktreePath), "handoffs") - os.MkdirAll(handoffDir, 0755) + if err := state.EnsureDir(handoffDir, 0700); err != nil { + return "", fmt.Errorf("cannot create handoff directory: %v", err) + } destPath := filepath.Join(handoffDir, name+".md") projectHandoffDir := filepath.Join(root, ".taskcapsule", "handoff") - os.MkdirAll(projectHandoffDir, 0755) + if err := state.EnsureDir(projectHandoffDir, 0700); err != nil { + return "", fmt.Errorf("cannot create project handoff directory: %v", err) + } projectPath := filepath.Join(projectHandoffDir, name+".md") - if err := os.WriteFile(destPath, []byte(handoffMD), 0644); err != nil { + if err := os.WriteFile(destPath, []byte(handoffMD), 0600); err != nil { return "", fmt.Errorf("cannot write handoff: %v", err) } - os.WriteFile(projectPath, []byte(handoffMD), 0644) + if err := os.WriteFile(projectPath, []byte(handoffMD), 0600); err != nil { + return "", fmt.Errorf("cannot write project handoff: %v", err) + } return destPath, nil } diff --git a/internal/app/helpers.go b/internal/app/helpers.go index 5e6d88c..1e167e3 100644 --- a/internal/app/helpers.go +++ b/internal/app/helpers.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/exec" + "regexp" + "sort" "strings" "time" @@ -13,6 +15,8 @@ import ( "github.com/vtino17/taskcapsule/internal/state" ) +var portPlaceholderPattern = regexp.MustCompile(`\$\{PORT:([A-Za-z_][A-Za-z0-9_]*)\}`) + func execInDir(command []string, dir string) *exec.Cmd { cmd := exec.Command(command[0], command[1:]...) cmd.Dir = dir @@ -23,53 +27,65 @@ type serviceEnv struct { Ports map[string]int } -func buildServiceEnv(svcCfg config.ServiceConfig, allocator *ports.Allocator) *serviceEnv { +func buildServiceEnv(svcCfg config.ServiceConfig, allocator *ports.Allocator) (*serviceEnv, error) { env := &serviceEnv{Ports: make(map[string]int)} - // Allocate ports for this service - scan all env vars for ${PORT:name} - for key, val := range svcCfg.Environment { - _ = key - if strings.HasPrefix(val, "${PORT:") && strings.HasSuffix(val, "}") { - name := val[7 : len(val)-1] - if _, ok := env.Ports[name]; !ok { - port, _ := allocator.Allocate() - env.Ports[name] = port + for _, value := range svcCfg.Environment { + for _, match := range portPlaceholderPattern.FindAllStringSubmatch(value, -1) { + name := match[1] + if _, ok := env.Ports[name]; ok { + continue + } + port, err := allocator.Allocate() + if err != nil { + return nil, err } - } - } - - if portVar, ok := svcCfg.Environment["PORT"]; ok { - if strings.HasPrefix(portVar, "${PORT:") && strings.HasSuffix(portVar, "}") { - name := portVar[7 : len(portVar)-1] - port, _ := allocator.Allocate() env.Ports[name] = port } } - return env + return env, nil } func strPtr(s string) *string { return &s } -func setErrorState(state *capsule.State, err error, cs *state.Store, repoID, name string) { +func setErrorState(state *capsule.State, err error, cs *state.Store, repoID, name string) error { state.Status = "error" state.LastError = strPtr(err.Error()) state.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, state) + return cs.Save(repoID, name, state) } -func setupEnv(cmd *exec.Cmd, svcEnv *serviceEnv) { - cmd.Env = os.Environ() - - if svcEnv == nil { - return +func setupEnv(cmd *exec.Cmd, svcCfg config.ServiceConfig, svcEnv *serviceEnv) { + env := make(map[string]string) + for _, name := range []string{"PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "COMSPEC", "PATHEXT"} { + if value, ok := os.LookupEnv(name); ok { + env[name] = value + } + } + for _, name := range svcCfg.InheritEnvironment { + if value, ok := os.LookupEnv(name); ok { + env[name] = value + } + } + for name, value := range svcCfg.Environment { + env[name] = resolvePortVar(value, svcEnv.Ports) } - for svcName, port := range svcEnv.Ports { key := fmt.Sprintf("PORT_%s", strings.ToUpper(svcName)) - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", key, port)) + env[key] = fmt.Sprintf("%d", port) + } + + keys := make([]string, 0, len(env)) + for key := range env { + keys = append(keys, key) + } + sort.Strings(keys) + cmd.Env = make([]string, 0, len(keys)) + for _, key := range keys { + cmd.Env = append(cmd.Env, key+"="+env[key]) } } diff --git a/internal/app/helpers_test.go b/internal/app/helpers_test.go new file mode 100644 index 0000000..2361748 --- /dev/null +++ b/internal/app/helpers_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "os/exec" + "strings" + "testing" + + "github.com/vtino17/taskcapsule/internal/config" + "github.com/vtino17/taskcapsule/internal/ports" +) + +func TestBuildServiceEnvAllocatesEachPlaceholderOnce(t *testing.T) { + cfg := config.ServiceConfig{Environment: map[string]string{ + "PORT": "${PORT:api}", + "URL": "http://127.0.0.1:${PORT:api}/health", + }} + + env, err := buildServiceEnv(cfg, ports.NewAllocator()) + if err != nil { + t.Fatal(err) + } + if len(env.Ports) != 1 || env.Ports["api"] <= 0 { + t.Fatalf("expected one allocated api port, got %#v", env.Ports) + } +} + +func TestSetupEnvUsesExplicitInheritance(t *testing.T) { + t.Setenv("TASKCAPSULE_TEST_SECRET", "must-not-leak") + t.Setenv("TASKCAPSULE_TEST_ALLOWED", "allowed") + + cfg := config.ServiceConfig{ + Environment: map[string]string{"APP_MODE": "test", "PORT": "${PORT:api}"}, + InheritEnvironment: []string{"TASKCAPSULE_TEST_ALLOWED"}, + } + cmd := exec.Command("ignored") + setupEnv(cmd, cfg, &serviceEnv{Ports: map[string]int{"api": 43210}}) + joined := strings.Join(cmd.Env, "\n") + + if strings.Contains(joined, "TASKCAPSULE_TEST_SECRET=") { + t.Fatal("unlisted environment variable leaked to child process") + } + for _, expected := range []string{ + "TASKCAPSULE_TEST_ALLOWED=allowed", + "APP_MODE=test", + "PORT=43210", + "PORT_API=43210", + } { + if !strings.Contains(joined, expected) { + t.Errorf("expected %q in child environment: %v", expected, cmd.Env) + } + } +} diff --git a/internal/app/lock.go b/internal/app/lock.go index 375394f..a2a58a5 100644 --- a/internal/app/lock.go +++ b/internal/app/lock.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/vtino17/taskcapsule/internal/lock" - "github.com/vtino17/taskcapsule/internal/state" ) type capsuleLock struct { @@ -34,21 +33,3 @@ func (cl *capsuleLock) Release() { } cl.lock.Release() } - -func lockAndLoad(repoID, capsuleName, command string) (*capsuleLock, *state.Store, error) { - cl, err := acquireCapsuleLock(repoID, capsuleName, command) - if err != nil { - return nil, nil, err - } - - stateBase, _ := getStateDir() - cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, capsuleName) - if err != nil { - cl.Release() - return nil, nil, err - } - _ = s // Caller uses returned store - - return cl, cs, nil -} diff --git a/internal/app/logs.go b/internal/app/logs.go index 1f49021..3f51fe8 100644 --- a/internal/app/logs.go +++ b/internal/app/logs.go @@ -5,12 +5,22 @@ import ( "os" "path/filepath" + "github.com/vtino17/taskcapsule/internal/capsule" "github.com/vtino17/taskcapsule/internal/git" ) var defaultLogReader = DefaultLogReader func ShowLogs(name string, opts LogOptions) ([]byte, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + if opts.ServiceName != "" { + if err := capsule.ValidateName(opts.ServiceName); err != nil { + return nil, fmt.Errorf("invalid service name: %w", err) + } + } + root, err := findGitRoot() if err != nil { return nil, err @@ -21,7 +31,10 @@ func ShowLogs(name string, opts LogOptions) ([]byte, error) { return nil, err } - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } logDir := filepath.Join(stateBase, "capsules", repoID, name, "logs") if opts.ServiceName != "" { diff --git a/internal/app/note.go b/internal/app/note.go index 1aa7e25..477fee7 100644 --- a/internal/app/note.go +++ b/internal/app/note.go @@ -10,6 +10,10 @@ import ( ) func SaveNote(name, text string) error { + if err := validateCapsuleName(name); err != nil { + return err + } + root, err := findGitRoot() if err != nil { return err @@ -26,10 +30,13 @@ func SaveNote(name, text string) error { } defer cl.Release() - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return fmt.Errorf("capsule not found: %s", name) } diff --git a/internal/app/pause.go b/internal/app/pause.go index f8cba8c..712a015 100644 --- a/internal/app/pause.go +++ b/internal/app/pause.go @@ -10,6 +10,10 @@ import ( ) func Pause(name string) (*PauseResult, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + root, err := findGitRoot() if err != nil { return nil, err @@ -26,10 +30,13 @@ func Pause(name string) (*PauseResult, error) { } defer cl.Release() - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } @@ -44,7 +51,9 @@ func Pause(name string) (*PauseResult, error) { s.Status = "pausing" s.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + return nil, fmt.Errorf("failed to persist pausing state: %v", err) + } var svcInfos []ServiceInfo @@ -72,7 +81,9 @@ func Pause(name string) (*PauseResult, error) { s.Status = "paused" s.UpdatedAt = now s.LastPausedAt = &now - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + return nil, fmt.Errorf("services stopped but paused state could not be persisted: %v", err) + } return &PauseResult{Services: svcInfos}, nil } diff --git a/internal/app/resume.go b/internal/app/resume.go index 26f5976..7b12dff 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,10 @@ import ( ) func Resume(name string, opts ResumeOptions) (*ResumeResult, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + root, err := findGitRoot() if err != nil { return nil, err @@ -27,10 +32,13 @@ func Resume(name string, opts ResumeOptions) (*ResumeResult, error) { return nil, err } - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } @@ -51,7 +59,7 @@ func Resume(name string, opts ResumeOptions) (*ResumeResult, error) { defer cl.Release() // Reload state under lock - s, err = cs.Load(repoID, name) + s, err = loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } @@ -62,14 +70,17 @@ func Resume(name string, opts ResumeOptions) (*ResumeResult, error) { s.Status = "resuming" s.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + return nil, fmt.Errorf("failed to persist resuming state: %v", err) + } if opts.RunSetup { for _, setup := range cfg.Setup { cmd := execInDir(setup.Command, s.WorktreePath) if output, err := cmd.CombinedOutput(); err != nil { - setErrorState(s, fmt.Errorf("setup failed on resume: %v\n%s", err, string(output)), cs, repoID, name) - return nil, fmt.Errorf("setup failed: %v\n%s", err, string(output)) + setupErr := fmt.Errorf("setup failed: %v\n%s", err, string(output)) + persistErr := setErrorState(s, setupErr, cs, repoID, name) + return nil, errors.Join(setupErr, persistErr) } } } @@ -86,14 +97,18 @@ func Resume(name string, opts ResumeOptions) (*ResumeResult, error) { // Sequential startup with rollback started, serviceInfos, startErr := startServices(cfg, stateBase, repoID, name, s.WorktreePath, s, cs) if startErr != nil { - rollbackServices(started, s, cs) - setErrorState(s, startErr, cs, repoID, name) - return nil, startErr + rollbackServices(started, s) + persistErr := setErrorState(s, startErr, cs, repoID, name) + return nil, errors.Join(startErr, persistErr) } s.Status = "running" s.UpdatedAt = time.Now().UTC() - cs.Save(repoID, name, s) + if err := cs.Save(repoID, name, s); err != nil { + rollbackServices(started, s) + persistErr := setErrorState(s, err, cs, repoID, name) + return nil, errors.Join(fmt.Errorf("services started but running state could not be persisted: %w", err), persistErr) + } return &ResumeResult{ Services: serviceInfos, diff --git a/internal/app/start.go b/internal/app/start.go index 21d04c0..3fda727 100644 --- a/internal/app/start.go +++ b/internal/app/start.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" "path/filepath" @@ -91,6 +92,9 @@ func Start(name string, opts StartOptions) (*StartResult, error) { } worktreesDir := filepath.Join(stateBase, "worktrees") + if err := state.EnsureDir(filepath.Join(worktreesDir, repoName), 0700); err != nil { + return nil, fmt.Errorf("cannot create managed worktree directory: %v", err) + } worktreePath := filepath.Join(worktreesDir, repoName, slug) inUse, _ := git.BranchInUse(capsuleBranch, root) @@ -121,8 +125,8 @@ func Start(name string, opts StartOptions) (*StartResult, error) { newState.Status = "error" newState.LastError = strPtr(fmt.Sprintf("failed to create worktree: %v", err)) newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) - return nil, fmt.Errorf("failed to create worktree: %v\nPath: %s", err, worktreePath) + persistErr := cs.Save(repoID, slug, newState) + return nil, errors.Join(fmt.Errorf("failed to create worktree: %v\nPath: %s", err, worktreePath), persistErr) } // Run setup commands @@ -132,15 +136,18 @@ func Start(name string, opts StartOptions) (*StartResult, error) { newState.Status = "error" newState.LastError = strPtr(fmt.Sprintf("setup failed: %v", err)) newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) - return nil, fmt.Errorf("setup command failed: %s\n%s", strings.Join(setup.Command, " "), string(output)) + setupErr := fmt.Errorf("setup command failed: %s\n%s", strings.Join(setup.Command, " "), string(output)) + persistErr := cs.Save(repoID, slug, newState) + return nil, errors.Join(setupErr, persistErr) } } if opts.NoServices { newState.Status = "running" newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) + if err := cs.Save(repoID, slug, newState); err != nil { + return nil, fmt.Errorf("worktree created but running state could not be persisted: %v", err) + } return &StartResult{ Name: slug, Branch: capsuleBranch, @@ -152,14 +159,18 @@ func Start(name string, opts StartOptions) (*StartResult, error) { // Sequential service startup with health checks and rollback started, serviceInfos, startErr := startServices(cfg, stateBase, repoID, slug, worktreePath, newState, cs) if startErr != nil { - rollbackServices(started, newState, cs) - setErrorState(newState, startErr, cs, repoID, slug) - return nil, startErr + rollbackServices(started, newState) + persistErr := setErrorState(newState, startErr, cs, repoID, slug) + return nil, errors.Join(startErr, persistErr) } newState.Status = "running" newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) + if err := cs.Save(repoID, slug, newState); err != nil { + rollbackServices(started, newState) + persistErr := setErrorState(newState, err, cs, repoID, slug) + return nil, errors.Join(fmt.Errorf("services started but running state could not be persisted: %w", err), persistErr) + } return &StartResult{ Name: slug, @@ -175,18 +186,33 @@ func startServices(cfg *config.Config, stateBase, repoID, slug, worktreePath str var started []startedService for svcName, svcCfg := range cfg.Services { - env := buildServiceEnv(svcCfg, allocator) + env, err := buildServiceEnv(svcCfg, allocator) + if err != nil { + return started, nil, fmt.Errorf("cannot allocate ports for service %s: %v", svcName, err) + } logDir := filepath.Join(stateBase, "capsules", repoID, slug, "logs") - os.MkdirAll(logDir, 0755) + if err := state.EnsureDir(logDir, 0700); err != nil { + return started, nil, fmt.Errorf("cannot create service log directory: %v", err) + } logFile := filepath.Join(logDir, svcName+".log") - f, err := os.Create(logFile) + f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return started, nil, fmt.Errorf("cannot create log file for %s: %v", svcName, err) } - cmd := execInDir(svcCfg.Command, worktreePath) + serviceDir := worktreePath + if svcCfg.WorkingDirectory != "" { + serviceDir = filepath.Join(worktreePath, svcCfg.WorkingDirectory) + } + if err := config.ValidateWorkDir(serviceDir, worktreePath); err != nil { + _ = f.Close() + return started, nil, fmt.Errorf("invalid working directory for service %s: %v", svcName, err) + } + + cmd := execInDir(svcCfg.Command, serviceDir) + setupEnv(cmd, svcCfg, env) cmd.Stdout = f cmd.Stderr = f process.SetProcessGroup(cmd) @@ -198,6 +224,14 @@ func startServices(cfg *config.Config, stateBase, repoID, slug, worktreePath str pgid := process.GetProcessGroup(cmd) pid := cmd.Process.Pid + if err := f.Close(); err != nil { + if pgid > 0 { + process.StopProcessGroup(pgid, 5) + } else { + process.StopProcess(pid, 5) + } + return started, nil, fmt.Errorf("cannot close parent log handle for service %s: %v", svcName, err) + } port := env.Ports[svcName] svc := startedService{ @@ -222,7 +256,9 @@ func startServices(cfg *config.Config, stateBase, repoID, slug, worktreePath str } newState.Services[svcName] = svcState newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) + if err := cs.Save(repoID, slug, newState); err != nil { + return started, nil, fmt.Errorf("cannot persist starting service %s: %v", svcName, err) + } // Health check if svcCfg.Health != nil { @@ -275,7 +311,9 @@ func startServices(cfg *config.Config, stateBase, repoID, slug, worktreePath str svcState.Status = "running" newState.Services[svcName] = svcState newState.UpdatedAt = time.Now().UTC() - cs.Save(repoID, slug, newState) + if err := cs.Save(repoID, slug, newState); err != nil { + return started, nil, fmt.Errorf("cannot persist running service %s: %v", svcName, err) + } } serviceInfos := make([]ServiceInfo, 0, len(started)) @@ -291,9 +329,7 @@ func startServices(cfg *config.Config, stateBase, repoID, slug, worktreePath str return started, serviceInfos, nil } -func rollbackServices(started []startedService, state *capsule.State, cs *state.Store) { - repoID := state.RepositoryID - +func rollbackServices(started []startedService, state *capsule.State) { for i := len(started) - 1; i >= 0; i-- { s := started[i] if s.PGID > 0 { @@ -312,7 +348,6 @@ func rollbackServices(started []startedService, state *capsule.State, cs *state. state.Status = "error" state.UpdatedAt = time.Now().UTC() - cs.Save(repoID, state.Name, state) } func resolvePortVar(s string, ports map[string]int) string { diff --git a/internal/app/status.go b/internal/app/status.go index 9ad724c..0897f21 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -9,6 +9,10 @@ import ( ) func Status(name string) (*StatusInfo, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + root, err := findGitRoot() if err != nil { return nil, err @@ -19,10 +23,13 @@ func Status(name string) (*StatusInfo, error) { return nil, err } - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } diff --git a/internal/app/validation.go b/internal/app/validation.go new file mode 100644 index 0000000..fd3a8fd --- /dev/null +++ b/internal/app/validation.go @@ -0,0 +1,74 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/vtino17/taskcapsule/internal/capsule" + "github.com/vtino17/taskcapsule/internal/state" +) + +func validateCapsuleName(name string) error { + if err := capsule.ValidateName(name); err != nil { + return fmt.Errorf("invalid capsule name: %w", err) + } + return nil +} + +func validateManagedWorktreePath(stateBase, worktreePath string) error { + if worktreePath == "" { + return fmt.Errorf("capsule state has an empty worktree path") + } + + managedRoot, err := filepath.Abs(filepath.Join(stateBase, "worktrees")) + if err != nil { + return fmt.Errorf("resolve managed worktree root: %w", err) + } + path, err := filepath.Abs(worktreePath) + if err != nil { + return fmt.Errorf("resolve capsule worktree path: %w", err) + } + + rel, err := filepath.Rel(managedRoot, path) + if err != nil { + return fmt.Errorf("compare capsule worktree path with managed root: %w", err) + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("unsafe capsule worktree path %q: must be below %q", worktreePath, managedRoot) + } + + resolvedRoot, rootErr := filepath.EvalSymlinks(managedRoot) + resolvedPath, pathErr := filepath.EvalSymlinks(path) + if rootErr == nil && pathErr == nil { + resolvedRel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return fmt.Errorf("compare resolved capsule worktree path with managed root: %w", err) + } + if resolvedRel == "." || resolvedRel == ".." || strings.HasPrefix(resolvedRel, ".."+string(filepath.Separator)) { + return fmt.Errorf("unsafe resolved capsule worktree path %q: must be below %q", resolvedPath, resolvedRoot) + } + } + + return nil +} + +func loadValidatedCapsule(cs *state.Store, stateBase, repoID, name string) (*capsule.State, error) { + s, err := cs.Load(repoID, name) + if err != nil { + return nil, err + } + if s.SchemaVersion != 1 { + return nil, fmt.Errorf("unsupported capsule state schema version %d", s.SchemaVersion) + } + if s.Name != name { + return nil, fmt.Errorf("capsule state name mismatch: expected %q, got %q", name, s.Name) + } + if s.RepositoryID != repoID { + return nil, fmt.Errorf("capsule state repository mismatch") + } + if err := validateManagedWorktreePath(stateBase, s.WorktreePath); err != nil { + return nil, err + } + return s, nil +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go new file mode 100644 index 0000000..309f838 --- /dev/null +++ b/internal/app/validation_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateManagedWorktreePath(t *testing.T) { + stateBase := t.TempDir() + managed := filepath.Join(stateBase, "worktrees", "repo", "capsule") + if err := validateManagedWorktreePath(stateBase, managed); err != nil { + t.Fatalf("managed path rejected: %v", err) + } + + unsafe := []string{ + stateBase, + filepath.Join(stateBase, "worktrees"), + filepath.Dir(stateBase), + filepath.Join(stateBase, "..", "outside"), + } + for _, path := range unsafe { + if err := validateManagedWorktreePath(stateBase, path); err == nil { + t.Errorf("unsafe path %q was accepted", path) + } + } +} + +func TestValidateManagedWorktreePathRejectsSymlinkEscape(t *testing.T) { + stateBase := t.TempDir() + managedRoot := filepath.Join(stateBase, "worktrees") + outside := t.TempDir() + if err := os.MkdirAll(managedRoot, 0700); err != nil { + t.Fatal(err) + } + link := filepath.Join(managedRoot, "escape") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if err := validateManagedWorktreePath(stateBase, link); err == nil { + t.Fatal("managed worktree symlink escape was accepted") + } +} + +func TestValidateCapsuleNameRejectsTraversal(t *testing.T) { + for _, name := range []string{"../escape", "../../escape", "service/name", `service\\name`} { + if err := validateCapsuleName(name); err == nil { + t.Errorf("unsafe capsule name %q was accepted", name) + } + } +} diff --git a/internal/app/where.go b/internal/app/where.go index 19c7dab..485f21b 100644 --- a/internal/app/where.go +++ b/internal/app/where.go @@ -9,6 +9,10 @@ import ( ) func Where(name string) (*WhereInfo, error) { + if err := validateCapsuleName(name); err != nil { + return nil, err + } + root, err := findGitRoot() if err != nil { return nil, err @@ -19,10 +23,13 @@ func Where(name string) (*WhereInfo, error) { return nil, err } - stateBase, _ := getStateDir() + stateBase, err := getStateDir() + if err != nil { + return nil, err + } cs := state.NewStore(stateBase) - s, err := cs.Load(repoID, name) + s, err := loadValidatedCapsule(cs, stateBase, repoID, name) if err != nil { return nil, fmt.Errorf("capsule not found: %s", name) } diff --git a/internal/checks/runner.go b/internal/checks/runner.go index 19f7ef5..f77cad5 100644 --- a/internal/checks/runner.go +++ b/internal/checks/runner.go @@ -53,7 +53,13 @@ func Run(worktreePath string, command []string) (*Result, error) { } func SaveLog(logDir string, result *Result) (string, error) { - if err := os.MkdirAll(logDir, 0755); err != nil { + if result == nil { + return "", fmt.Errorf("result must not be nil") + } + if err := os.MkdirAll(logDir, 0700); err != nil { + return "", err + } + if err := os.Chmod(logDir, 0700); err != nil { return "", err } @@ -63,7 +69,7 @@ func SaveLog(logDir string, result *Result) (string, error) { content := fmt.Sprintf("Command: %s\nDuration: %.1fs\nExit code: %d\n\n%s", result.Command, result.Duration.Seconds(), result.ExitCode, result.Output) - if err := os.WriteFile(logFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(logFile, []byte(content), 0600); err != nil { return "", err } diff --git a/internal/checks/runner_test.go b/internal/checks/runner_test.go index a4f312e..f0e7f6a 100644 --- a/internal/checks/runner_test.go +++ b/internal/checks/runner_test.go @@ -3,6 +3,7 @@ package checks import ( "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -46,8 +47,26 @@ func TestSaveLog(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := os.Stat(path); os.IsNotExist(err) { + info, err := os.Stat(path) + if os.IsNotExist(err) { t.Errorf("log file not created: %s", path) + } else if err != nil { + t.Fatal(err) + } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Errorf("expected log mode 0600, got %o", info.Mode().Perm()) + } + dirInfo, err := os.Stat(logDir) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && dirInfo.Mode().Perm() != 0700 { + t.Errorf("expected log directory mode 0700, got %o", dirInfo.Mode().Perm()) + } +} + +func TestSaveLogRejectsNilResult(t *testing.T) { + if _, err := SaveLog(t.TempDir(), nil); err == nil { + t.Fatal("expected nil result to be rejected") } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ccbb483..1ea1574 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "testing" ) @@ -113,6 +114,35 @@ func TestLoadEmptyCommand(t *testing.T) { } } +func TestLoadRejectsUnsafeMapKeys(t *testing.T) { + tests := []string{ + `{"version":1,"services":{"../../escape":{"command":["go","run","."]}}}`, + `{"version":1,"checks":{"../escape":{"command":["go","test","./..."]}}}`, + } + + for _, content := range tests { + f, err := os.CreateTemp("", "taskcapsule-*.json") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(content)); err != nil { + f.Close() + os.Remove(f.Name()) + t.Fatal(err) + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + t.Fatal(err) + } + + _, loadErr := Load(f.Name()) + os.Remove(f.Name()) + if loadErr == nil { + t.Fatalf("Load accepted unsafe config: %s", content) + } + } +} + func TestApplyDefaults(t *testing.T) { cfg := &Config{ Version: 1, @@ -150,3 +180,16 @@ func TestValidateCommand(t *testing.T) { } } } + +func TestValidateWorkDirRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + link := filepath.Join(root, "escape") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if err := ValidateWorkDir(link, root); err == nil { + t.Fatal("working-directory symlink escape was accepted") + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 2742a9a..4a99228 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -4,9 +4,14 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" + + "github.com/vtino17/taskcapsule/internal/capsule" ) +var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + func validate(cfg *Config) error { if cfg.Version < 1 || cfg.Version > 1 { return fmt.Errorf("unsupported schema version: %d (expected 1)", cfg.Version) @@ -14,6 +19,9 @@ func validate(cfg *Config) error { seen := make(map[string]bool) for name := range cfg.Services { + if err := capsule.ValidateName(name); err != nil { + return fmt.Errorf("invalid service name %q: %v", name, err) + } if seen[name] { return fmt.Errorf("duplicate service name: %s", name) } @@ -26,10 +34,36 @@ func validate(cfg *Config) error { if err := validateCommand(svc.Command); err != nil { return fmt.Errorf("service %q: %v", name, err) } + if filepath.IsAbs(svc.WorkingDirectory) { + return fmt.Errorf("service %q: working directory must be relative to the worktree", name) + } + for envName := range svc.Environment { + if !environmentNamePattern.MatchString(envName) { + return fmt.Errorf("service %q: invalid environment variable name %q", name, envName) + } + } + for _, envName := range svc.InheritEnvironment { + if !environmentNamePattern.MatchString(envName) { + return fmt.Errorf("service %q: invalid inherited environment variable name %q", name, envName) + } + } + if svc.Health != nil { + switch svc.Health.Type { + case "none", "process", "tcp", "http": + default: + return fmt.Errorf("service %q: unsupported health check type %q", name, svc.Health.Type) + } + if svc.Health.TimeoutSeconds < 0 { + return fmt.Errorf("service %q: health timeout must not be negative", name) + } + } } seenChecks := make(map[string]bool) for name := range cfg.Checks { + if err := capsule.ValidateName(name); err != nil { + return fmt.Errorf("invalid check name %q: %v", name, err) + } if seenChecks[name] { return fmt.Errorf("duplicate check name: %s", name) } @@ -72,11 +106,20 @@ func ValidateWorkDir(dir, worktreeRoot string) error { return err } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return fmt.Errorf("resolve worktree root %q: %w", worktreeRoot, err) + } + abs, err = filepath.EvalSymlinks(abs) + if err != nil { + return fmt.Errorf("resolve working directory %q: %w", dir, err) + } + rel, err := filepath.Rel(root, abs) if err != nil { return fmt.Errorf("working directory %q is outside worktree", dir) } - if strings.HasPrefix(rel, "..") { + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return fmt.Errorf("working directory %q is outside worktree root %q", dir, root) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 88d5a08..8139112 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -51,10 +51,32 @@ func TestRoot(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - abs, _ := filepath.Abs(dir) - abs = filepath.ToSlash(abs) - if root != abs { - t.Errorf("expected %s, got %s", abs, root) + want, err := canonicalRoot(dir) + if err != nil { + t.Fatalf("canonicalRoot: %v", err) + } + if root != want { + t.Errorf("expected %s, got %s", want, root) + } +} + +func TestRepoIDCanonicalizesSymlinkAliases(t *testing.T) { + dir := setupTestRepo(t) + alias := filepath.Join(t.TempDir(), "repo-alias") + if err := os.Symlink(dir, alias); err != nil { + t.Skipf("symlinks are unavailable: %v", err) + } + + directID, err := RepoID(dir) + if err != nil { + t.Fatalf("RepoID(direct): %v", err) + } + aliasID, err := RepoID(alias) + if err != nil { + t.Fatalf("RepoID(alias): %v", err) + } + if directID != aliasID { + t.Fatalf("expected canonical aliases to share an ID, got %s and %s", directID, aliasID) } } diff --git a/internal/git/repository.go b/internal/git/repository.go index e6fe781..68a0d06 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -8,15 +8,25 @@ import ( ) func Root() (string, error) { - return execGit("rev-parse", "--show-toplevel") + root, err := execGit("rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + return canonicalRoot(root) } func RepoID(root string) (string, error) { - // Normalize path separators so that C:\foo\bar and C:/foo/bar - // produce the same ID on Windows. - normalized := filepath.ToSlash(root) + canonical, err := canonicalRoot(root) + if err != nil { + return "", fmt.Errorf("canonicalize repository root: %w", err) + } + + // Normalize path separators so that equivalent platform spellings produce + // the same fallback ID. Resolving symlinks also handles macOS's /var -> + // /private/var alias consistently. + normalized := filepath.ToSlash(canonical) - remote, err := execGitInDir(normalized, "remote", "get-url", "origin") + remote, err := execGitInDir(canonical, "remote", "get-url", "origin") if err != nil { h := sha256.Sum256([]byte(normalized)) return fmt.Sprintf("%x", h[:8]), nil @@ -26,6 +36,18 @@ func RepoID(root string) (string, error) { return fmt.Sprintf("%x", h[:8]), nil } +func canonicalRoot(root string) (string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + return filepath.ToSlash(filepath.Clean(resolved)), nil +} + func RepoName(root string) (string, error) { remote, err := execGitInDir(root, "remote", "get-url", "origin") if err != nil { diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go index d4b2123..5d74091 100644 --- a/internal/lock/lock_test.go +++ b/internal/lock/lock_test.go @@ -93,3 +93,13 @@ func TestReleaseNilLock(t *testing.T) { t.Fatalf("release nil lock should not error: %v", err) } } + +func TestManagerRejectsUnsafeKeys(t *testing.T) { + manager := NewManager(t.TempDir()) + if _, err := manager.Acquire("../outside", "safe", "test"); err == nil { + t.Fatal("unsafe repository ID was accepted") + } + if _, err := manager.Acquire("0123456789abcdef", "../../outside", "test"); err == nil { + t.Fatal("unsafe capsule name was accepted") + } +} diff --git a/internal/lock/manager.go b/internal/lock/manager.go index 5250893..1a14b8d 100644 --- a/internal/lock/manager.go +++ b/internal/lock/manager.go @@ -1,10 +1,16 @@ package lock import ( + "fmt" "os" "path/filepath" + "regexp" + + "github.com/vtino17/taskcapsule/internal/capsule" ) +var repoIDPattern = regexp.MustCompile(`^[a-f0-9]{16}$`) + type Manager struct { locksDir string } @@ -21,10 +27,19 @@ func (m *Manager) lockPath(repoID, capsuleName string) string { } func (m *Manager) Acquire(repoID, capsuleName, command string) (*Lock, error) { + if !repoIDPattern.MatchString(repoID) { + return nil, fmt.Errorf("invalid repository ID") + } + if err := capsule.ValidateName(capsuleName); err != nil { + return nil, err + } dir := filepath.Join(m.locksDir, repoID) if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } + if err := os.Chmod(dir, 0700); err != nil { + return nil, err + } path := m.lockPath(repoID, capsuleName) return Acquire(path, command) diff --git a/internal/state/atomic.go b/internal/state/atomic.go index 709eee8..00097e5 100644 --- a/internal/state/atomic.go +++ b/internal/state/atomic.go @@ -1,21 +1,48 @@ package state import ( + "fmt" "os" + "path/filepath" ) // AtomicWrite performs an atomic file write using temp file + rename. // The temp file is created in the same directory to ensure same-filesystem rename. func AtomicWrite(path string, data []byte, perm os.FileMode) error { - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, perm); err != nil { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { return err } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpPath) + }() - return os.Rename(tmpPath, path) + if err := tmp.Chmod(perm); err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace %s: %w", path, err) + } + + return nil } // EnsureDir creates a directory if it doesn't exist. func EnsureDir(path string, perm os.FileMode) error { - return os.MkdirAll(path, perm) + if err := os.MkdirAll(path, perm); err != nil { + return err + } + return os.Chmod(path, perm) } diff --git a/internal/state/atomic_test.go b/internal/state/atomic_test.go new file mode 100644 index 0000000..e5974a3 --- /dev/null +++ b/internal/state/atomic_test.go @@ -0,0 +1,51 @@ +package state + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestAtomicWriteReplacesContentAndAppliesMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := os.WriteFile(path, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + if err := AtomicWrite(path, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "new" { + t.Fatalf("expected replacement content, got %q", data) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Fatalf("expected mode 0600, got %o", info.Mode().Perm()) + } +} + +func TestEnsureDirTightensExistingPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "private") + if err := os.Mkdir(path, 0755); err != nil { + t.Fatal(err) + } + if err := EnsureDir(path, 0700); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0700 { + t.Fatalf("expected mode 0700, got %o", info.Mode().Perm()) + } +} diff --git a/internal/state/lock.go b/internal/state/lock.go deleted file mode 100644 index 60e364b..0000000 --- a/internal/state/lock.go +++ /dev/null @@ -1,89 +0,0 @@ -package state - -import ( - "crypto/sha256" - "fmt" - "os" - "strconv" - "strings" -) - -type Lock struct { - path string -} - -func NewLock(lockPath string) *Lock { - return &Lock{path: lockPath} -} - -// Acquire tries to create an exclusive lock file. -// Returns an error if the lock is held by a live process. -func (l *Lock) Acquire() error { - // Try to create lock file exclusively - data := []byte(fmt.Sprintf("%d\n", os.Getpid())) - if err := os.WriteFile(l.path, data, 0644); err != nil { - // Lock file exists - check if stale - return l.checkStale() - } - return nil -} - -// Release removes the lock file. -func (l *Lock) Release() error { - return os.Remove(l.path) -} - -// LockedBy returns the PID that holds the lock, or 0 if no lock. -func (l *Lock) LockedBy() int { - data, err := os.ReadFile(l.path) - if err != nil { - return 0 - } - - line := strings.TrimSpace(string(data)) - pid, err := strconv.Atoi(line) - if err != nil { - return 0 - } - return pid -} - -func (l *Lock) checkStale() error { - pid := l.LockedBy() - if pid <= 0 { - // Stale lock file - os.Remove(l.path) - return l.Acquire() - } - - proc, err := os.FindProcess(pid) - if err != nil { - // Process not found - stale lock - os.Remove(l.path) - return l.Acquire() - } - - // On Unix, signal 0 checks process existence - // On Windows, FindProcess always succeeds (known limitation) - if proc.Signal(os.Interrupt) != nil { - // Process not alive - stale lock - os.Remove(l.path) - return l.Acquire() - } - - return fmt.Errorf("capsule is locked by process %d", pid) -} - -// RepoIDFromRoot derives a stable repo ID from a repo root path. -// Uses the same algorithm as git.RepoID for consistency. -func RepoIDFromRoot(root string) (string, error) { - // Delegate to git package logic via import not possible due to cycle. - // Use same simple hash approach. - return getFallbackRepoID(root), nil -} - -func getFallbackRepoID(root string) string { - // Simple hash of the root path for repo identification - h := sha256.Sum256([]byte(root)) - return fmt.Sprintf("%x", h[:8]) -} diff --git a/internal/state/store.go b/internal/state/store.go index df63690..27420ab 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -5,11 +5,24 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "github.com/vtino17/taskcapsule/internal/capsule" ) +var repoIDPattern = regexp.MustCompile(`^[a-f0-9]{16}$`) + +func validateKey(repoID, name string) error { + if !repoIDPattern.MatchString(repoID) { + return fmt.Errorf("invalid repository ID") + } + if err := capsule.ValidateName(name); err != nil { + return fmt.Errorf("invalid capsule name: %w", err) + } + return nil +} + type Store struct { basePath string } @@ -31,31 +44,34 @@ func (s *Store) listCapsulesDir(repoID string) string { } func (s *Store) Save(repoID, name string, state *capsule.State) error { + if err := validateKey(repoID, name); err != nil { + return err + } + if state == nil { + return fmt.Errorf("capsule state must not be nil") + } dir := s.capsuleDir(repoID, name) - if err := os.MkdirAll(dir, 0700); err != nil { + if err := EnsureDir(dir, 0700); err != nil { return fmt.Errorf("cannot create state directory: %v", err) } statePath := s.stateFilePath(repoID, name) - tmpPath := statePath + ".tmp" - data, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("cannot marshal state: %v", err) } - if err := os.WriteFile(tmpPath, data, 0600); err != nil { + if err := AtomicWrite(statePath, data, 0600); err != nil { return fmt.Errorf("cannot write state: %v", err) } - if err := os.Rename(tmpPath, statePath); err != nil { - return fmt.Errorf("cannot finalize state: %v", err) - } - return nil } func (s *Store) Load(repoID, name string) (*capsule.State, error) { + if err := validateKey(repoID, name); err != nil { + return nil, err + } path := s.stateFilePath(repoID, name) data, err := os.ReadFile(path) @@ -75,11 +91,17 @@ func (s *Store) Load(repoID, name string) (*capsule.State, error) { } func (s *Store) Delete(repoID, name string) error { + if err := validateKey(repoID, name); err != nil { + return err + } dir := s.capsuleDir(repoID, name) return os.RemoveAll(dir) } func (s *Store) List(repoID string) ([]*capsule.State, error) { + if !repoIDPattern.MatchString(repoID) { + return nil, fmt.Errorf("invalid repository ID") + } capsulesDir := s.listCapsulesDir(repoID) return s.readCapsuleDir(capsulesDir) } diff --git a/internal/state/store_test.go b/internal/state/store_test.go index d0893d7..f96b8c0 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -8,6 +8,8 @@ import ( "github.com/vtino17/taskcapsule/internal/capsule" ) +const testRepoID = "0123456789abcdef" + func TestSaveAndLoad(t *testing.T) { dir, err := os.MkdirTemp("", "taskcapsule-state-*") if err != nil { @@ -24,11 +26,11 @@ func TestSaveAndLoad(t *testing.T) { UpdatedAt: time.Now(), } - if err := store.Save("repo1", "test-capsule", state); err != nil { + if err := store.Save(testRepoID, "test-capsule", state); err != nil { t.Fatalf("Save failed: %v", err) } - loaded, err := store.Load("repo1", "test-capsule") + loaded, err := store.Load(testRepoID, "test-capsule") if err != nil { t.Fatalf("Load failed: %v", err) } @@ -49,7 +51,7 @@ func TestLoadNotFound(t *testing.T) { defer os.RemoveAll(dir) store := NewStore(dir) - _, err = store.Load("repo1", "nonexistent") + _, err = store.Load(testRepoID, "nonexistent") if err == nil { t.Fatal("expected error for nonexistent capsule") } @@ -70,12 +72,12 @@ func TestDelete(t *testing.T) { UpdatedAt: time.Now(), } - store.Save("repo1", "test", state) - if err := store.Delete("repo1", "test"); err != nil { + store.Save(testRepoID, "test", state) + if err := store.Delete(testRepoID, "test"); err != nil { t.Fatalf("Delete failed: %v", err) } - _, err = store.Load("repo1", "test") + _, err = store.Load(testRepoID, "test") if err == nil { t.Fatal("expected error after delete") } @@ -89,10 +91,10 @@ func TestList(t *testing.T) { defer os.RemoveAll(dir) store := NewStore(dir) - store.Save("repo1", "capsule-a", &capsule.State{Name: "capsule-a", CreatedAt: time.Now(), UpdatedAt: time.Now()}) - store.Save("repo1", "capsule-b", &capsule.State{Name: "capsule-b", CreatedAt: time.Now(), UpdatedAt: time.Now()}) + store.Save(testRepoID, "capsule-a", &capsule.State{Name: "capsule-a", CreatedAt: time.Now(), UpdatedAt: time.Now()}) + store.Save(testRepoID, "capsule-b", &capsule.State{Name: "capsule-b", CreatedAt: time.Now(), UpdatedAt: time.Now()}) - capsules, err := store.List("repo1") + capsules, err := store.List(testRepoID) if err != nil { t.Fatalf("List failed: %v", err) } @@ -100,3 +102,29 @@ func TestList(t *testing.T) { t.Errorf("expected 2 capsules, got %d", len(capsules)) } } + +func TestRejectsUnsafeStorageKeys(t *testing.T) { + store := NewStore(t.TempDir()) + value := &capsule.State{SchemaVersion: 1, Name: "safe"} + + tests := []struct { + repoID string + name string + }{ + {repoID: "../outside", name: "safe"}, + {repoID: testRepoID, name: "../../outside"}, + {repoID: testRepoID, name: "service/name"}, + } + + for _, test := range tests { + if err := store.Save(test.repoID, test.name, value); err == nil { + t.Fatalf("Save(%q, %q) accepted an unsafe storage key", test.repoID, test.name) + } + if _, err := store.Load(test.repoID, test.name); err == nil { + t.Fatalf("Load(%q, %q) accepted an unsafe storage key", test.repoID, test.name) + } + if err := store.Delete(test.repoID, test.name); err == nil { + t.Fatalf("Delete(%q, %q) accepted an unsafe storage key", test.repoID, test.name) + } + } +} diff --git a/scripts/build-release-artifacts.sh b/scripts/build-release-artifacts.sh old mode 100644 new mode 100755 index 39103a9..877e165 --- a/scripts/build-release-artifacts.sh +++ b/scripts/build-release-artifacts.sh @@ -1,24 +1,46 @@ #!/bin/bash -set -eu +set -euo pipefail + +umask 022 +export TZ=UTC OUTPUT_DIR="${1:-./dist}" VERSION="${VERSION:-0.0.0-dev}" COMMIT="${COMMIT:-unknown}" -BUILD_DATE="${BUILD_DATE:-$(date -u +%Y-%m-%d)}" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || printf '315532800')}" +BUILD_DATE="${BUILD_DATE:-$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d)}" -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-_].+)?$ ]]; then +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-][0-9A-Za-z.-]+)?$ ]]; then echo "Error: version must be in semver format (e.g. 1.0.0 or 1.0.0-dry-run), got: $VERSION" >&2 exit 1 fi +if [[ ! "$COMMIT" =~ ^[0-9A-Za-z.-]+$ ]]; then + echo "Error: commit contains unsupported characters" >&2 + exit 1 +fi +if [[ ! "$BUILD_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "Error: build date must use YYYY-MM-DD" >&2 + exit 1 +fi +if [[ ! "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || (( SOURCE_DATE_EPOCH < 315532800 )); then + echo "Error: SOURCE_DATE_EPOCH must be a Unix timestamp on or after 1980-01-01" >&2 + exit 1 +fi mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd -P)" +STAGING_ROOT="$(mktemp -d "$OUTPUT_DIR/.taskcapsule-release.XXXXXX")" +trap 'rm -rf -- "$STAGING_ROOT"' EXIT +ARTIFACT_FILES=() build_target() { local goos="$1" goarch="$2" ext="$3" local binary="taskcapsule${ext}" local artifact="taskcapsule_${VERSION}_${goos}_${goarch}" + local staging="$STAGING_ROOT/$artifact" echo "Building $artifact..." + mkdir -p "$staging" GOOS="$goos" GOARCH="$goarch" go build \ -trimpath \ @@ -26,18 +48,23 @@ build_target() { -X github.com/vtino17/taskcapsule/internal/version.Version=${VERSION} \ -X github.com/vtino17/taskcapsule/internal/version.Commit=${COMMIT} \ -X github.com/vtino17/taskcapsule/internal/version.BuildDate=${BUILD_DATE}" \ - -o "$OUTPUT_DIR/$artifact/$binary" \ + -o "$staging/$binary" \ ./cmd/taskcapsule - cp LICENSE README.md "$OUTPUT_DIR/$artifact/" + cp LICENSE README.md "$staging/" + touch -d "@$SOURCE_DATE_EPOCH" "$staging/$binary" "$staging/LICENSE" "$staging/README.md" if [ "$goos" = "windows" ]; then - (cd "$OUTPUT_DIR/$artifact" && zip -q "../${artifact}.zip" ./*) + (cd "$staging" && zip -X -q "$OUTPUT_DIR/${artifact}.zip" ./*) + ARTIFACT_FILES+=("${artifact}.zip") else - tar czf "$OUTPUT_DIR/${artifact}.tar.gz" -C "$OUTPUT_DIR/${artifact}" . + tar --sort=name \ + --mtime="@$SOURCE_DATE_EPOCH" \ + --owner=0 --group=0 --numeric-owner \ + -cf - -C "$staging" . | gzip -n > "$OUTPUT_DIR/${artifact}.tar.gz" + ARTIFACT_FILES+=("${artifact}.tar.gz") fi - rm -rf "$OUTPUT_DIR/$artifact" if [ "$goos" = "windows" ]; then echo " -> $OUTPUT_DIR/${artifact}.zip" else @@ -52,5 +79,5 @@ build_target darwin arm64 "" build_target windows amd64 ".exe" cd "$OUTPUT_DIR" -sha256sum *.tar.gz *.zip > checksums.txt +printf '%s\n' "${ARTIFACT_FILES[@]}" | sort | xargs sha256sum > checksums.txt echo "Checksums written to $OUTPUT_DIR/checksums.txt" diff --git a/scripts/verify-reproducible-release.sh b/scripts/verify-reproducible-release.sh new file mode 100755 index 0000000..68b325e --- /dev/null +++ b/scripts/verify-reproducible-release.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +OUTPUT_DIR="${1:-./dist}" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct)}" +export SOURCE_DATE_EPOCH + +VERIFY_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/taskcapsule-reproducible.XXXXXX")" +FIRST="$VERIFY_ROOT/first" +SECOND="$VERIFY_ROOT/second" +trap 'rm -rf -- "$VERIFY_ROOT"' EXIT + +mkdir -p "$FIRST" "$SECOND" "$OUTPUT_DIR" +bash scripts/build-release-artifacts.sh "$FIRST" +bash scripts/build-release-artifacts.sh "$SECOND" + +diff -r "$FIRST" "$SECOND" +cp "$FIRST"/* "$OUTPUT_DIR"/ +echo "Release artifacts are reproducible for SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH"