diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e5d555..55ee651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Check formatting run: | # Collect all .go files across all modules. - unformatted=$(find registry -name '*.go' -type f -exec gofmt -l {} +) + unformatted=$(find cmd internal registry -name '*.go' -type f -exec gofmt -l {} +) if [ -n "$unformatted" ]; then echo "::error::The following files are not gofmt-clean:" echo "$unformatted" @@ -54,6 +54,9 @@ jobs: - name: Run go vet on all modules run: | set -e + echo "::group::go vet: root CLI" + go vet ./cmd/... ./internal/... + echo "::endgroup::" modules=( middleware auth @@ -90,6 +93,9 @@ jobs: - name: Run tests for all modules run: | set -e + echo "::group::go test: root CLI" + go test -count=1 -timeout 10m ./cmd/... ./internal/... + echo "::endgroup::" modules=( middleware auth @@ -134,6 +140,9 @@ jobs: - name: Verify each module compiles run: | set -e + echo "::group::go build: root CLI" + go build ./cmd/scion + echo "::endgroup::" modules=( middleware auth @@ -154,13 +163,32 @@ jobs: done echo "All modules compile successfully." + # =========================================================================== + # Job 5: Bundle freshness — registry edits must update the embedded bundle + # =========================================================================== + bundle-check: + name: bundle check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Regenerate embedded registry bundle + run: go run ./internal/cmd/build-bundle + + - name: Verify bundle is fresh + run: git diff --exit-code -- internal/bundle + # =========================================================================== # Summary job — required for branch protection # =========================================================================== ci-success: name: CI Success runs-on: ubuntu-latest - needs: [gofmt, go-vet, go-test, module-check] + needs: [gofmt, go-vet, go-test, module-check, bundle-check] if: always() steps: - name: Check all jobs @@ -168,7 +196,8 @@ jobs: if [[ "${{ needs.gofmt.result }}" != "success" || \ "${{ needs.go-vet.result }}" != "success" || \ "${{ needs.go-test.result }}" != "success" || \ - "${{ needs.module-check.result }}" != "success" ]]; then + "${{ needs.module-check.result }}" != "success" || \ + "${{ needs.bundle-check.result }}" != "success" ]]; then echo "::error::CI failed. One or more checks did not pass." exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index a0bb3a3..7e63984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,12 @@ ## Project Description -Scion is a copy-paste code library for Go backend development. It contains 11 self-contained modules in `registry/` — each is a standalone Go package with zero external dependencies (Go standard library only). Modules are meant to be copied into a user's project and adapted, not imported as a dependency. +Scion is a copy-paste code library for Go backend development. It contains 11 self-contained modules in `registry/` — each is a standalone Go package. Modules are standard-library only by default; security-sensitive modules may be declared as `stdlibOnly:false` in `registry/index.json` and copied in standalone mode. Modules are meant to be copied into a user's project and adapted, not imported as a dependency. ## Coding Standards - Go 1.22+ with generics -- Zero external dependencies — standard library only +- Standard library only by default; declared `stdlibOnly:false` modules may use mature security libraries - `gofmt` formatting is mandatory - `go vet` must pass with zero warnings - Middleware signature: `func(http.Handler) http.Handler` @@ -69,7 +69,7 @@ foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Po ## Key Constraints -- Do NOT add external dependencies to any module's `go.mod` +- Do NOT add external dependencies to any module's `go.mod` unless the module is explicitly marked `stdlibOnly:false` in `registry/index.json` and the dependency is justified for security or correctness - Do NOT use `panic` in HTTP handlers — return errors - Do NOT trust client headers (`Content-Type`, `X-Forwarded-For`, `X-Real-Ip`) - Do NOT use `strings.Split` for header parsing — use `strings.SplitN` with a limit @@ -89,9 +89,9 @@ You are working on Scion, a copy-paste code library for Go backend development. Project location: Architecture: -- 11 modules in registry/ — each is a standalone Go package, zero external deps +- 11 modules in registry/ — each is a standalone Go package - Module path pattern: registry//src/go/ -- Go 1.22+, standard library only, gofmt mandatory +- Go 1.22+, standard library by default, gofmt mandatory Security rules (non-negotiable): 1. Reject CRLF (\r\n) and null bytes (\x00) in all user inputs diff --git a/AGENTS_zh.md b/AGENTS_zh.md index ebb2198..0a1976c 100644 --- a/AGENTS_zh.md +++ b/AGENTS_zh.md @@ -6,12 +6,12 @@ ## 项目描述 -Scion 是一个面向 Go 后端开发的复制粘贴代码库。`registry/` 目录下有 11 个自包含模块,每个都是独立的 Go 包,零外部依赖(仅使用 Go 标准库)。模块的设计目的是被复制到用户项目中并适配,而非作为依赖导入。 +Scion 是一个面向 Go 后端开发的复制粘贴代码库。`registry/` 目录下有 11 个自包含模块,每个都是独立的 Go 包。模块默认仅使用标准库;安全敏感模块可以在 `registry/index.json` 中显式标记为 `stdlibOnly:false`,并以 standalone 模式复制。模块的设计目的是被复制到用户项目中并适配,而非作为依赖导入。 ## 编码标准 - Go 1.22+,使用泛型 -- 零外部依赖 — 仅使用标准库 +- 默认仅使用标准库;显式标记为 `stdlibOnly:false` 的模块可以使用成熟安全库 - `gofmt` 格式化是强制的 - `go vet` 必须零警告通过 - 中间件签名:`func(http.Handler) http.Handler` @@ -69,7 +69,7 @@ foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Po ## 关键约束 -- 不要给任何模块的 `go.mod` 添加外部依赖 +- 不要给任何模块的 `go.mod` 添加外部依赖,除非该模块已在 `registry/index.json` 显式标记为 `stdlibOnly:false`,且依赖对安全或正确性有明确价值 - 不要在 HTTP 处理器中使用 `panic` — 返回错误 - 不要信任客户端 header(`Content-Type`、`X-Forwarded-For`、`X-Real-Ip`) - 不要用 `strings.Split` 解析 header — 用 `strings.SplitN` 加限制 @@ -89,9 +89,9 @@ foreach ($m in $modules) { Push-Location "registry/$m/src/go"; go test ./...; Po 项目路径: 架构: -- registry/ 下有 11 个模块 — 每个是独立的 Go 包,零外部依赖 +- registry/ 下有 11 个模块 — 每个是独立的 Go 包 - 模块路径模式: registry/<模块名>/src/go/ -- Go 1.22+,仅使用标准库,gofmt 强制 +- Go 1.22+,默认仅使用标准库,gofmt 强制 安全规则(不可妥协): 1. 拒绝所有用户输入中的 CRLF (\r\n) 和 null 字节 (\x00) diff --git a/README.md b/README.md index a437f38..c8246ea 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,31 @@ Backend modules (auth, CRUD, file upload, rate limiting) share 80% of their skel - You need to customize business logic deep inside the module - You want to own the code, not be locked to upstream versions - Your AI coding assistant works better with code it can read and modify directly -- No dependency hell — zero external dependencies, Go standard library only +- No dependency hell — standard library by default, with declared security exceptions ## Quick Start ```bash -# 1. Copy a module into your project -cp -r registry/auth/src/go/* yourproject/internal/auth/ +# 1. Install the source-template copier +go install github.com/DarkInno/scion/cmd/scion@latest + +# 2. Copy a zero-dependency module into your project +scion add cache --to internal/cache -# 2. Adapt the configuration -# Edit config.go: set JWT secret, database URL, etc. +# 3. Inspect local changes against the embedded template later +scion diff cache --target internal/cache +``` -# 3. Implement the store interface -# type UserStore interface { ... } // your DB layer +Scion's CLI copies source files and writes `.scion-module.json` metadata for later comparison. It never edits your `go.mod` automatically. Modules marked `stdlibOnly=false` must be copied with `--standalone` so their `go.mod`/`go.sum` are explicit. -# 4. Wire up routes -# See registry/auth/examples/gin/main.go +Manual copy still works: + +```bash +# 1. Copy a module into your project +cp -r registry/cache/src/go/*.go yourproject/internal/cache/ + +# 2. Adapt the package to your project +# Rename, trim tests, or wire it into your service as needed. ``` ## Available Modules @@ -79,7 +88,7 @@ scion/ ## Design Principles 1. **Code ownership** — every line is yours after copying. No upstream lock-in. -2. **Self-contained** — each module works independently, zero external dependencies. +2. **Self-contained** — each module works independently; external dependencies are allowed only for declared security exceptions. 3. **Framework-agnostic** — uses Go standard `net/http`, adaptable to Gin/Echo/etc. 4. **Security-first** — input validation, rate limiting, injection prevention built in. 5. **AI-friendly** — `__llms__.md` files let AI assistants understand modules in ~200 tokens. @@ -89,9 +98,15 @@ scion/ ```bash # Clone the repository -git clone https://github.com/your-org/scion.git +git clone https://github.com/DarkInno/scion.git cd scion +# Regenerate the embedded CLI bundle after registry changes +go run ./internal/cmd/build-bundle + +# Test the root CLI +go test ./cmd/... ./internal/... + # Run tests for a specific module cd registry/auth/src/go && go test -v ./... diff --git a/README_zh.md b/README_zh.md index a42abd1..c7f5f2d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,7 +13,7 @@ Scion 是一个面向 Go 后端开发的复制粘贴代码库。你不需要安 - 你需要在模块深处自定义业务逻辑 - 你想拥有代码的所有权,而不是被上游版本锁定 - 你的 AI 编码助手在能直接阅读和修改代码时表现更好 -- 没有依赖地狱 — 零外部依赖,仅使用 Go 标准库 +- 没有依赖地狱 — 默认仅使用 Go 标准库,安全例外会显式声明 ## 快速开始 @@ -80,7 +80,7 @@ scion/ ## 设计原则 1. **代码所有权** — 复制后每一行代码都是你的,没有上游锁定。 -2. **自包含** — 每个模块独立工作,零外部依赖。 +2. **自包含** — 每个模块独立工作;只有显式声明的安全例外才允许外部依赖。 3. **框架无关** — 使用 Go 标准 `net/http`,可适配 Gin/Echo 等。 4. **安全优先** — 内置输入验证、限流、注入防护。 5. **AI 友好** — `__llms__.md` 文件让 AI 助手在约 200 token 内理解模块。 diff --git a/cmd/scion/main.go b/cmd/scion/main.go new file mode 100644 index 0000000..adfa95a --- /dev/null +++ b/cmd/scion/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/DarkInno/scion/internal/bundle" + "github.com/DarkInno/scion/internal/cli" + "github.com/DarkInno/scion/internal/registry" +) + +func main() { + reg, err := registry.NewBundle(bundle.RegistryZip, bundle.ManifestJSON) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "scion: failed to load embedded registry: %v\n", err) + os.Exit(1) + } + + app := cli.New(reg) + os.Exit(app.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/docs/contributing.md b/docs/contributing.md index 7638734..15323c4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -53,7 +53,7 @@ Add your module to `registry/index.json`. ## Code Standards - Go 1.22+ with generics -- Zero external dependencies — standard library only +- Standard library only by default; external dependencies require an explicit `stdlibOnly:false` registry entry and security/correctness justification - `gofmt` formatting - `go vet` must pass - Middleware signature: `func(http.Handler) http.Handler` diff --git a/docs/guide/why-copy-paste.md b/docs/guide/why-copy-paste.md index a6a5d0c..94c29c3 100644 --- a/docs/guide/why-copy-paste.md +++ b/docs/guide/why-copy-paste.md @@ -7,7 +7,7 @@ Backend modules (auth, CRUD, file upload, rate limiting) share 80% of their skel - You need to customize business logic deep inside the module - You want to own the code, not be locked to upstream versions - Your AI coding assistant works better with code it can read and modify directly -- No dependency hell — zero external dependencies, Go standard library only +- No dependency hell — standard library by default, with declared security exceptions ## The Solution @@ -19,7 +19,7 @@ Scion provides copy-paste code modules. Instead of installing a framework or pul Every line is yours after copying. No upstream lock-in. No version conflicts. No waiting for maintainers to merge your PR. -### Zero Dependencies +### Explicit Dependencies Each module uses only the Go standard library. No `go.sum` bloat. No transitive dependency vulnerabilities. @@ -53,7 +53,7 @@ Every module includes functional tests and penetration test cases. Run `go test - You want to own every line of code - You need deep customization - You're building with AI coding assistants -- You want zero external dependencies +- You want no hidden transitive dependencies ## When NOT to Use Scion diff --git a/docs/index.md b/docs/index.md index 44bf007..ff901ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Scion text: Copy-Paste Go Backend Modules - tagline: Zero dependencies, security-first, AI-friendly. Copy production-ready code into your project. + tagline: Explicit dependencies, security-first, AI-friendly. Copy production-ready code into your project. actions: - theme: brand text: Get Started @@ -84,6 +84,6 @@ cp -r registry/auth/src/go/* yourproject/internal/auth/ Backend modules share 80% of their skeleton across projects. Instead of installing a framework, copy pre-built, production-ready modules and own every line of code. - **Code ownership** — every line is yours after copying -- **Zero dependencies** — Go standard library only +- **Explicit dependencies** — standard library by default; security exceptions are declared - **Security-first** — input validation, rate limiting, injection prevention built in - **AI-friendly** — `__llms__.md` files let AI assistants understand modules quickly diff --git a/docs/modules/index.md b/docs/modules/index.md index c2ab2c2..bf247a3 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -1,6 +1,6 @@ # Modules Overview -Scion provides 11 production-ready, copy-paste Go modules. Each module is self-contained with zero external dependencies. +Scion provides 11 production-ready, copy-paste Go modules. Each module is self-contained. Modules are standard-library only by default; declared security exceptions are marked in the registry. ## Available Modules @@ -53,4 +53,4 @@ go test -v ./... ## Dependencies -All modules use only the Go standard library. No external dependencies required. +Modules use only the Go standard library by default. Declared exceptions, such as auth, copy their own `go.mod` in standalone mode. diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 4fc38d5..261f9bb 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -53,7 +53,7 @@ go test -v ./... ## 代码标准 - Go 1.22+ 支持泛型 -- 零外部依赖 — 仅标准库 +- 默认仅使用标准库;外部依赖必须有显式的 `stdlibOnly:false` 注册表声明,并说明安全或正确性理由 - `gofmt` 格式化 - `go vet` 必须通过 - 中间件签名:`func(http.Handler) http.Handler` diff --git a/docs/zh/guide/why-copy-paste.md b/docs/zh/guide/why-copy-paste.md index acc0611..51852d5 100644 --- a/docs/zh/guide/why-copy-paste.md +++ b/docs/zh/guide/why-copy-paste.md @@ -7,7 +7,7 @@ - 你需要深入模块内部定制业务逻辑 - 你想拥有代码,而不是被上游版本锁定 - 你的 AI 编程助手能更好地理解和修改直接可见的代码 -- 没有依赖地狱 — 零外部依赖,仅使用 Go 标准库 +- 没有依赖地狱 — 默认仅使用 Go 标准库,安全例外会显式声明 ## 解决方案 @@ -19,9 +19,9 @@ Scion 提供复制粘贴代码模块。与其安装框架或引入依赖,不 复制后每一行都是你的。没有上游锁定。没有版本冲突。不用等待维护者合并你的 PR。 -### 零依赖 +### 显式依赖 -每个模块仅使用 Go 标准库。没有 `go.sum` 膨胀。没有传递依赖漏洞。 +模块默认仅使用 Go 标准库;安全敏感模块如认证可以显式声明外部安全库。没有隐藏的 `go.sum` 膨胀,也没有未声明的传递依赖。 ### 安全优先 @@ -45,7 +45,7 @@ Scion 提供复制粘贴代码模块。与其安装框架或引入依赖,不 |------|------|------| | **包管理 (npm/go)** | 安装简单,自动更新 | 版本锁定,依赖地狱,难以定制 | | **框架 (Gin/Echo)** | 统一API,社区支持 | 锁定,臃肿,学习曲线 | -| **复制粘贴 (Scion)** | 完全拥有,零依赖,可定制 | 手动更新,初始工作量稍大 | +| **复制粘贴 (Scion)** | 完全拥有,依赖显式,可定制 | 手动更新,初始工作量稍大 | ## 何时使用 Scion @@ -53,7 +53,7 @@ Scion 提供复制粘贴代码模块。与其安装框架或引入依赖,不 - 你想拥有每一行代码 - 你需要深度定制 - 你使用 AI 编程助手 -- 你想要零外部依赖 +- 你不想要隐藏的传递依赖 ## 何时不使用 Scion diff --git a/docs/zh/index.md b/docs/zh/index.md index 6ba779d..9c1e3e8 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Scion text: 复制粘贴 Go 后端模块 - tagline: 零依赖、安全优先、AI友好。复制生产就绪的代码到你的项目中。 + tagline: 显式依赖、安全优先、AI友好。复制生产就绪的代码到你的项目中。 actions: - theme: brand text: 快速开始 @@ -84,6 +84,6 @@ cp -r registry/auth/src/go/* yourproject/internal/auth/ 后端模块在不同项目间共享 80% 的骨架代码。与其安装框架,不如复制生产就绪的模块,拥有每一行代码。 - **代码所有权** — 复制后每一行都是你的 -- **零依赖** — 仅使用 Go 标准库 +- **显式依赖** — 默认仅使用标准库;安全例外会声明 - **安全优先** — 内置输入验证、限流、注入防护 - **AI友好** — `__llms__.md` 文件让 AI 快速理解模块 diff --git a/docs/zh/modules/index.md b/docs/zh/modules/index.md index 847ffa1..b349ec9 100644 --- a/docs/zh/modules/index.md +++ b/docs/zh/modules/index.md @@ -1,6 +1,6 @@ # 模块概览 -Scion 提供 11 个生产就绪、可复制粘贴的 Go 模块。每个模块自包含,零外部依赖。 +Scion 提供 11 个生产就绪、可复制粘贴的 Go 模块。每个模块自包含。模块默认仅使用标准库;安全例外会在 registry 中显式标记。 ## 可用模块 @@ -53,4 +53,4 @@ go test -v ./... ## 依赖 -所有模块仅使用 Go 标准库,无外部依赖。 +模块默认仅使用 Go 标准库。显式例外(如 auth)会在 standalone 模式下复制自己的 `go.mod`。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cfb76c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/DarkInno/scion + +go 1.22 diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go new file mode 100644 index 0000000..bab57c8 --- /dev/null +++ b/internal/bundle/bundle.go @@ -0,0 +1,9 @@ +package bundle + +import _ "embed" + +//go:embed registry.zip +var RegistryZip []byte + +//go:embed manifest.json +var ManifestJSON []byte diff --git a/internal/bundle/manifest.json b/internal/bundle/manifest.json new file mode 100644 index 0000000..75ea502 --- /dev/null +++ b/internal/bundle/manifest.json @@ -0,0 +1,1182 @@ +{ + "schemaVersion": 1, + "registryVersion": "0.1.0", + "bundleHash": "18284db2be7ca31829f14a922988ce14aab2f5a2f224077c8e6ad7157ac7e003", + "modules": [ + { + "id": "auth", + "version": "0.1.0", + "source": "registry/auth/src/go" + }, + { + "id": "cache", + "version": "0.1.0", + "source": "registry/cache/src/go" + }, + { + "id": "crud", + "version": "0.1.0", + "source": "registry/crud/src/go" + }, + { + "id": "file-upload", + "version": "0.1.0", + "source": "registry/file-upload/src/go" + }, + { + "id": "health", + "version": "0.1.0", + "source": "registry/health/src/go" + }, + { + "id": "mail", + "version": "0.1.0", + "source": "registry/mail/src/go" + }, + { + "id": "middleware", + "version": "0.1.0", + "source": "registry/middleware/src/go" + }, + { + "id": "pagination", + "version": "0.1.0", + "source": "registry/pagination/src/go" + }, + { + "id": "ratelimit", + "version": "0.1.0", + "source": "registry/ratelimit/src/go" + }, + { + "id": "rbac", + "version": "0.1.0", + "source": "registry/rbac/src/go" + }, + { + "id": "validation", + "version": "0.1.0", + "source": "registry/validation/src/go" + } + ], + "files": [ + { + "path": "registry/auth/README.md", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "24ef698dcb4077244742806359c60f7bb6a435e9f9eeb64f0f7452e8810ac6ae", + "size": 3334 + }, + { + "path": "registry/auth/__llms__.md", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "b77fa71344b6fc373232de31526119148f24a2e0b0e055022d22995cbdf55e74", + "size": 1568 + }, + { + "path": "registry/auth/examples/gin/main.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "b9cb1f01f75739041ad3871b8f483dcb1505c6f095f00f417ca26e77f9d654be", + "size": 1103 + }, + { + "path": "registry/auth/src/go/config.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "8ae37cf9cada70a8d13fbd7bd289cac5ae07ef863935773624f86ce00e8b57f2", + "size": 1944 + }, + { + "path": "registry/auth/src/go/config_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "bcf630f2d08581efa920dfc814463ab2ef4cc6e8a0e601702590cbbc33f6784a", + "size": 4634 + }, + { + "path": "registry/auth/src/go/go.mod", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "47e9920e0cfaf560cfd6ccd48ebcf2fe6dc65d00aefa2526fcf99c5dd38367c5", + "size": 100 + }, + { + "path": "registry/auth/src/go/go.sum", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "82d2f12ad4b56508da1f072afe6bc531707aea13442cd5bf218bb7e499ff4202", + "size": 334 + }, + { + "path": "registry/auth/src/go/handlers.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "552c82e00c6eb87771f39987bf3e3bf4aec162b76a676f1aed01c545b4d80b06", + "size": 6555 + }, + { + "path": "registry/auth/src/go/handlers_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "b0b29d3b202902b9da2ba4dee70981f32c96664ca593fa08633ec905ff473028", + "size": 10671 + }, + { + "path": "registry/auth/src/go/jwt.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "ff2d1c5eae5357a83b28833698781e812ff38741f535d529a2e561f58121a52c", + "size": 2826 + }, + { + "path": "registry/auth/src/go/jwt_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "83496fe8e26efefe4cc463c5a998b4294d036c6dcbe7c4fb2b851717bb672604", + "size": 4990 + }, + { + "path": "registry/auth/src/go/middleware.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "93f845d0c82bca04f18a98e784fdd01d4ef9f3d8039b9d6eafa261b31ed974ae", + "size": 1531 + }, + { + "path": "registry/auth/src/go/middleware_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "f8a4af407ea2804484c17f509754168d26ca46fe5de8078013e12172995d379e", + "size": 4784 + }, + { + "path": "registry/auth/src/go/models.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "82651f4234d46d9b2e992a2037aca4982302c7d2b723c872cb4eded0c5f34d97", + "size": 4020 + }, + { + "path": "registry/auth/src/go/models_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "4d25afb2cd5c2c0b76ca260e54cf638e687d65113f5132bb4304020efd7a2704", + "size": 5116 + }, + { + "path": "registry/auth/src/go/password.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "a1aed534a0e09199a7ab8636e6536284dc3f2304c5a1d48cccbba1611f8b7356", + "size": 1806 + }, + { + "path": "registry/auth/src/go/password_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "17569ae58853fe6cc55ecd6562f00f23ea5b85ee7793069e8665b1366ffd5d6e", + "size": 3039 + }, + { + "path": "registry/auth/src/go/pentest_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "531201e95b927488e49849df56ad55c38e2848d2bf0b41855e42bc1da7c6286f", + "size": 7512 + }, + { + "path": "registry/auth/src/go/ratelimiter.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "9d9689d593100e829f8411dd5da41a02ef1314fc9e12ef1b040fe63a18c82dab", + "size": 3990 + }, + { + "path": "registry/auth/src/go/ratelimiter_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "0d2be26c7b7923ceef25eb8b5ee69067e70e996fe8d950cf62ad61b7d0c7b0d0", + "size": 2709 + }, + { + "path": "registry/auth/src/go/routes.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "3ae981f61b64c786d4335767657a07fdcc711efd1e1941efc6afd2eedcc21c30", + "size": 1551 + }, + { + "path": "registry/auth/src/go/routes_test.go", + "module": "auth", + "moduleVersion": "0.1.0", + "sha256": "c7c209846a38339a4e3b2d73ab3517d9fd82290955dd62f933b70643f5fdd7d7", + "size": 2125 + }, + { + "path": "registry/cache/README.md", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "e3d28f4b849089d161c95d4c2a91f9557794dcc96ce198242586898e6c9b3f16", + "size": 991 + }, + { + "path": "registry/cache/__llms__.md", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "aa64521da3708e3df6f388c308b726bbfac95d8d6267ce32fbff3ef4c05d2fff", + "size": 398 + }, + { + "path": "registry/cache/src/go/go.mod", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "39ecc09787069a0e587bf10323d33b3d6388ad5df97d5560d3d7c1b9b751ab2c", + "size": 22 + }, + { + "path": "registry/cache/src/go/memory.go", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "0c77b5f6f260416cb49fb97bb866977e61f4f5dfcac158873ab565c857bd1813", + "size": 7126 + }, + { + "path": "registry/cache/src/go/memory_test.go", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "a3824490e7bf70717e7a452e889a561a8f3bee08bc0f7b2e1c45aa19df4bd6c3", + "size": 8093 + }, + { + "path": "registry/cache/src/go/pentest_test.go", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "00f736ec5d587996db17b47d315316c1820b089522b430c676697c0b7638a949", + "size": 6205 + }, + { + "path": "registry/cache/src/go/store.go", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "0b3c27224aa7c63ac69b0178bd9e17415fbf4371cba20e9d9cfec812f6562a58", + "size": 5215 + }, + { + "path": "registry/cache/src/go/store_test.go", + "module": "cache", + "moduleVersion": "0.1.0", + "sha256": "99ff0f8b9b01bd51d2edcdf46a718c9fb1b16ad73207f8ad04e84fbb67592377", + "size": 1289 + }, + { + "path": "registry/crud/README.md", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "56d6c211948f20ecc7525e3d76f607c1779eec4cd1ebe783a57ecd54779dc08e", + "size": 3011 + }, + { + "path": "registry/crud/__llms__.md", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "0010fdd7b37a3aae24ebdc9a83fb91b778d9114f88d913e002d4cc46ad0f66b9", + "size": 718 + }, + { + "path": "registry/crud/examples/gin/main.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "c0c259c5f9dd265afb8b25394ff724ee1bb794ce98764b0f9611e9decf061a3e", + "size": 2368 + }, + { + "path": "registry/crud/src/go/config.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "bc038ade6432d3625c6430a710e126628747a23ddf9e66cccccefd044bc6c105", + "size": 1342 + }, + { + "path": "registry/crud/src/go/config_test.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "60f2c93e37b33c3c08d47ac41b0391c05d44caf89856c9d7d2114a1b3b922c00", + "size": 2135 + }, + { + "path": "registry/crud/src/go/go.mod", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "f4089f1edc1636104d59ac51ef2fa7991ec02149647c35751c6a36fc71c5d545", + "size": 21 + }, + { + "path": "registry/crud/src/go/handlers.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "9693b7588a165f4c218be244fb456d72e014ea9ab539b1eb51ed886affed0793", + "size": 6610 + }, + { + "path": "registry/crud/src/go/handlers_test.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "de92b97b818cb0a64c5203399d1c1f5c222893187b7eb6e439e6b5f025e331cf", + "size": 10024 + }, + { + "path": "registry/crud/src/go/models.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "83e98a31eb098af89e91172992ab9372f13c3e20b583a9ef3be7733c000c9a19", + "size": 2995 + }, + { + "path": "registry/crud/src/go/models_test.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "7b3e0c5a6cc7521931ce41d03176d8023bc879ecfbcfcddb90cd6aaec41f445a", + "size": 3569 + }, + { + "path": "registry/crud/src/go/pentest_test.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "d971b4ef9942d82ae8e1a5825e67d9925577f8c6f079180c061667dd2bd433e5", + "size": 7847 + }, + { + "path": "registry/crud/src/go/routes.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "1e739b4a72a04c5e4b9e8c16e7590620d0f7e8c5a8f72e8d6a458643698cd0cf", + "size": 1296 + }, + { + "path": "registry/crud/src/go/routes_test.go", + "module": "crud", + "moduleVersion": "0.1.0", + "sha256": "962e48fd141f103df2a6648e3232c4b796a9e2356ad3adcf88b6ecdf946d7713", + "size": 3052 + }, + { + "path": "registry/file-upload/README.md", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "256750bb515ee1e3a80dc3122f1ec28a3e8e16831eabf8d59034f2fc983e3fc6", + "size": 1230 + }, + { + "path": "registry/file-upload/__llms__.md", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "eeb904f02a6efc88d0485a662b22ebc77ca621eb2347822223e04f9977178d2b", + "size": 427 + }, + { + "path": "registry/file-upload/src/go/config.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "2c940d5462022a887dfb743d74d09fb2d8fc32b8fccfbdc417cff5fafd3115ba", + "size": 4770 + }, + { + "path": "registry/file-upload/src/go/config_test.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "62acf19ee88bd6451d10a6cb3fef72c5c8b4508fc8ce8d982b264f979e4a3a4f", + "size": 1257 + }, + { + "path": "registry/file-upload/src/go/go.mod", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "cdc9c73070c383bc338b662769e3f76b71ca95e404e0d8411ef95ebc94994816", + "size": 235 + }, + { + "path": "registry/file-upload/src/go/handler.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "61c69a7c81940ac879c73de88f6ba6c4ab221ab37d45da72a264a276e38b4a09", + "size": 8967 + }, + { + "path": "registry/file-upload/src/go/handler_test.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "4a812c0b58e0a9609d2f60cb15f82f7230bbbd2d1bd66a4a1d10791b63f7b5b3", + "size": 25413 + }, + { + "path": "registry/file-upload/src/go/pentest_test.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "c5b0e03094b3f00b24be6d4afd0dfd6a71a56f1128b7be8f735e16ffd49a97f3", + "size": 12376 + }, + { + "path": "registry/file-upload/src/go/storage.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "311c0244f5d12942048e892a7bcce92b1e8c3d25f5c7715194fb1823f1623cfa", + "size": 5572 + }, + { + "path": "registry/file-upload/src/go/storage_test.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "a1b57a3ba9c83933e4c780b98402e0b571b8205d76a67accd184a8abb2be886a", + "size": 1265 + }, + { + "path": "registry/file-upload/src/go/validate.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "bcb2b6e8b04f0b5d23002362873ec63ea68d5833dd711dd63e9c425a6cf59c5b", + "size": 4500 + }, + { + "path": "registry/file-upload/src/go/validate_test.go", + "module": "file-upload", + "moduleVersion": "0.1.0", + "sha256": "6be8fb24fd58e355f0361fc433bb5d880df86573f5e4ccdbccaac5a1a5d2ca6d", + "size": 1009 + }, + { + "path": "registry/health/README.md", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "9bcb4faa16b2f5325c8afe11ec793029d6777634b606ca17e9b7dad2446ae1b2", + "size": 1090 + }, + { + "path": "registry/health/__llms__.md", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "3fe64aad29a5bbb926bb12870a3092162a087654530f99d68dec200aec6c3552", + "size": 418 + }, + { + "path": "registry/health/src/go/checker.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "1c125cb3de2b34b01191574827130bbd417d83881e9ad31c3d665fe561ce9c9f", + "size": 7479 + }, + { + "path": "registry/health/src/go/checker_test.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "1d2c44d40d200f6e5fb9d308fd87a2c98a434691c4b88c32bba1188813fd3538", + "size": 18349 + }, + { + "path": "registry/health/src/go/checks.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "376e5560bf1c1ab071f4906f22b6d3e740647ca7ddd5c89f78b1acf84fd6ad7f", + "size": 9146 + }, + { + "path": "registry/health/src/go/checks_test.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "c4f439518725a8ff621ce983de3c6d1783232202a075412fe1a772c363c5ff37", + "size": 1317 + }, + { + "path": "registry/health/src/go/go.mod", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "3c782271592d98b4e10cc7208d4579375ec765ed0b929fa05c860ee9626baa46", + "size": 23 + }, + { + "path": "registry/health/src/go/handler.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "08b785f0ee41e4d75e851104cf71107176e4d3370f4c58d792b290f30701db4a", + "size": 2559 + }, + { + "path": "registry/health/src/go/handler_test.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "6c3c930a75c8572343c1e1f80a250158bcecd0e79d65acbb1b39472ee7374e78", + "size": 1064 + }, + { + "path": "registry/health/src/go/pentest_test.go", + "module": "health", + "moduleVersion": "0.1.0", + "sha256": "eee1b312a437ad51dbbf960f959f6f1bdbeee5ecd05f0f5e7206379a5456a872", + "size": 13958 + }, + { + "path": "registry/index.json", + "sha256": "535534825c49a7ee784b67e65b7d9d74b79e89f5b102739e61e96ab0633f6452", + "size": 6856 + }, + { + "path": "registry/mail/README.md", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "56f93457fa16357510984523b2c14f6e5942068421aa15e3c714b2709891541d", + "size": 1178 + }, + { + "path": "registry/mail/__llms__.md", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "451e635c69ebf55bda661e9a97e0ba767a72aa6d9a62913837a2c87486ab82d1", + "size": 365 + }, + { + "path": "registry/mail/src/go/config.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "a665c0d1e8a785ae8afe13a0e68893387b2adab7814ffb0e5320e5f004699fc5", + "size": 2567 + }, + { + "path": "registry/mail/src/go/config_test.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "8101a6db2e18382fac2c0f9ccec123d9311803f42a1aa13d92e3bb95dc3a3137", + "size": 944 + }, + { + "path": "registry/mail/src/go/go.mod", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "518541b9e863a80ff3586832c37ad6539ad54702302bd8480039badd1397433d", + "size": 21 + }, + { + "path": "registry/mail/src/go/message.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "d7fe001a9b9a0ed94c4a434e58c65a54a27068e8b53b462fd5c2fa522529ada2", + "size": 3331 + }, + { + "path": "registry/mail/src/go/message_test.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "d5138000034313abca452f0006fd5273647b760e0b02a1d6ad41133eb29e5686", + "size": 926 + }, + { + "path": "registry/mail/src/go/pentest_test.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "f7eb42d8c18f5bae7291ed17962ef2fee1ecc518d130b024c4ba495389b35a31", + "size": 5635 + }, + { + "path": "registry/mail/src/go/sender.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "4380c737a10154a2e85c9be31b5e9314c2e43b1111715304cf43010fbba7b8f3", + "size": 9037 + }, + { + "path": "registry/mail/src/go/sender_test.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "245a1c788f1171e2f5bf0943e32ab673eda37b46e53b54408367777c31e5444e", + "size": 6223 + }, + { + "path": "registry/mail/src/go/template.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "4b18458b496da6f4b7dfba4ceba415466c154d6a305c530b606e1600c9ffb47e", + "size": 2626 + }, + { + "path": "registry/mail/src/go/template_test.go", + "module": "mail", + "moduleVersion": "0.1.0", + "sha256": "9c655bb6fd4c7920b782c4f7f757c0dc171d82bc85084f03c44c6d7846882f60", + "size": 884 + }, + { + "path": "registry/middleware/README.md", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "e939d764afb8632eb0dbe4b7d6f0bd874580f8689330aeee1076c67ccd1c23c3", + "size": 1275 + }, + { + "path": "registry/middleware/__llms__.md", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "2dc85347a3e97b9fd809857dfe396f562130bfe4c3792518172346dc09e2c8f7", + "size": 434 + }, + { + "path": "registry/middleware/src/go/bodylimit.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "3ecd056b975bc67338d0e676fa07d0b6263dff710a91da8d7a8a59617a563dce", + "size": 1030 + }, + { + "path": "registry/middleware/src/go/bodylimit_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "52b4b34f72f774d0cc465cff5bf44a4a7525ead38b5126601a52ed8ed2907daa", + "size": 3413 + }, + { + "path": "registry/middleware/src/go/chain.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "56c0fe475ee81d91b7aa8732bc54e219ea5456b3e427a255c5c8ffd80a6a0bb1", + "size": 2057 + }, + { + "path": "registry/middleware/src/go/chain_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "23cc3b0245f42600ab064be85ea64fbed7d5a3b5212872546b1f2c932c49dfa1", + "size": 5559 + }, + { + "path": "registry/middleware/src/go/config.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "9e4c17317971a22e9ebc37f9ee39b620b3fa86f43f9014e543d96ae890e3dbe4", + "size": 5460 + }, + { + "path": "registry/middleware/src/go/config_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "77e3b96ef36fb85e8411af306e3bf5736be7a877c7ab53b498028e6c6aabd0e4", + "size": 4196 + }, + { + "path": "registry/middleware/src/go/context.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "95a1f93ffcb86aba1f38df1e4bd822009eac346ae95c9a81f04328f902300c6d", + "size": 304 + }, + { + "path": "registry/middleware/src/go/context_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "e95724420105fd2b235592f6cf2841be2af05442880b4906daf0429fa14c1d26", + "size": 508 + }, + { + "path": "registry/middleware/src/go/cors.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "ebd309c25ed10ee7dd8cc10d98192caa64c1f4e5a39f80bb088b2a21e8e794fe", + "size": 5663 + }, + { + "path": "registry/middleware/src/go/cors_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "ff3879f6281d583850c3431353ae54c1b53e044f12081176921fa803c808cef7", + "size": 7724 + }, + { + "path": "registry/middleware/src/go/debug.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "86be3bed92a116afe45ec5c8c015b6277ea21c12bdd92b3b75dd802af3192b67", + "size": 2508 + }, + { + "path": "registry/middleware/src/go/debug_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "8ca9e46cca35e3a6d90e1fa10be603dba8d37c1f4631208cce2d7fdffa2a336f", + "size": 3156 + }, + { + "path": "registry/middleware/src/go/go.mod", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "97d5708e9fae395410d96ae195b7bac45e11fd3e1ebfaf1b5d8d271336f079e8", + "size": 27 + }, + { + "path": "registry/middleware/src/go/logging.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "5e3b8cc2acd21079e74d1a918fa487ec48dc779e640d627e64ad6b8c365f27ae", + "size": 4947 + }, + { + "path": "registry/middleware/src/go/logging_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "63a94f0d74cfe0e582de8851b1559ba853769e8d059e0b201f4cbab76ceef80c", + "size": 6710 + }, + { + "path": "registry/middleware/src/go/pentest_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "d5377ffa9a9178462e8331d7f1952fa24b8420ae89ff4a36ebf3d4b2995595a4", + "size": 10678 + }, + { + "path": "registry/middleware/src/go/proxy.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "076abf946207c97047c2d0bc979d3550687ba5c4f394f79a561efe33e873953a", + "size": 4624 + }, + { + "path": "registry/middleware/src/go/proxy_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "7389c8876c91bc8581f89791b28afccdb5372d67a1ffc383ac20e465196e72ac", + "size": 4051 + }, + { + "path": "registry/middleware/src/go/recovery.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "53dc2ba91a44b1cbfc48d92aeddb1868a36197d631a9d621cf6a8ae04380dbf3", + "size": 1713 + }, + { + "path": "registry/middleware/src/go/recovery_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "ebd590f4e474f47a96bc76467e0c5250b85203deac5ee5a255a75a25b935eecf", + "size": 5640 + }, + { + "path": "registry/middleware/src/go/requestid.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "941c71db1de2487aa75b33e8a922f174a76cd649db4345efaaf41e2f76e3e53e", + "size": 3509 + }, + { + "path": "registry/middleware/src/go/requestid_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "90b018ae5c8773ddbb09e9f77f8eeca9baa61292ae07f10fc8054d935ca6d70c", + "size": 5699 + }, + { + "path": "registry/middleware/src/go/rw.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "b988e1b8bbb53d14ee57ec205ad8489ebb27f6c335b519aa6aa934d36a78b7ed", + "size": 884 + }, + { + "path": "registry/middleware/src/go/rw_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "3d1ceff37c0f0e4a9a61e67cebfe42a2392c95967f66c3910919dedf9138d587", + "size": 891 + }, + { + "path": "registry/middleware/src/go/timeout.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "ad300a630668bdb008e2cb009069c20b23e246a62a33bb6fe01b9886ff81a358", + "size": 3515 + }, + { + "path": "registry/middleware/src/go/timeout_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "6faf06dc826c25e89fee476825805e5230679ebe168abccd50d397039ee9ed10", + "size": 5318 + }, + { + "path": "registry/middleware/src/go/trace.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "8f8b4a92c957007f1519f010fba0ab0f181cc8a704cc5f978ee8135ce9911ebd", + "size": 4398 + }, + { + "path": "registry/middleware/src/go/trace_test.go", + "module": "middleware", + "moduleVersion": "0.1.0", + "sha256": "9409273d4cea1da32fc091dda9d99b3d468191c4ceae93ea29c87dab9f4f90c2", + "size": 6411 + }, + { + "path": "registry/pagination/README.md", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "345ee74ac481e9cf6b7d8ecd2a7b70c17a45e57b411b54c5d830cbb43948d73c", + "size": 1056 + }, + { + "path": "registry/pagination/__llms__.md", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "84ba060355b7369267ec74f56863b6f4d360e5365eac32c861c810aea5e52296", + "size": 387 + }, + { + "path": "registry/pagination/src/go/config.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "d292414e8f2a53429ae48120a92a0a5c79ca43ca91b389779116e5baa81293fa", + "size": 2368 + }, + { + "path": "registry/pagination/src/go/config_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "da8317da522945bfeed5268c847dd386ff4dbe048c224c2ebeaa858ef54c9baf", + "size": 490 + }, + { + "path": "registry/pagination/src/go/cursor.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "6c83fa5b65fba49fd2aea9f639a59e33d7064bbf5b6787de322b550a77f26ba2", + "size": 5907 + }, + { + "path": "registry/pagination/src/go/cursor_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "2c2863bfc02954dd16bb250bf113e93cf32a99498d0528ca098f107164b4a21e", + "size": 8203 + }, + { + "path": "registry/pagination/src/go/go.mod", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "7632a2f67fca33b6f27d7867888937adbee615f1bd0367d7e17c3e48e703aa94", + "size": 27 + }, + { + "path": "registry/pagination/src/go/middleware.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "6a351c17b2c1e07713ef5295f8798ca5c5e4355004e5de0f696fbd8f375caa4f", + "size": 2428 + }, + { + "path": "registry/pagination/src/go/middleware_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "2df4b63fcd9d7bd201647124011039fa3f4f428b9a40f30daeef763f94eefc02", + "size": 983 + }, + { + "path": "registry/pagination/src/go/offset.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "5dd4739146c5d27d5accc1f6e3f8f3a82d3075535c27a6bace882830fc9b0019", + "size": 5048 + }, + { + "path": "registry/pagination/src/go/offset_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "bbc9be0e91aa328d131fe09716e93d8fadad8bc332ec8d312c0216b4acf2d200", + "size": 10504 + }, + { + "path": "registry/pagination/src/go/pentest_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "e6039f758a8206e3ca784e6f13da597c10ba1cd0f4a054c30cd40d5e47a8a11f", + "size": 9796 + }, + { + "path": "registry/pagination/src/go/response.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "4de20ca4b53b041c3c89da3fb5b22bb2c0ef5a26e93c2a3bc73a19e26f81a7a2", + "size": 2109 + }, + { + "path": "registry/pagination/src/go/response_test.go", + "module": "pagination", + "moduleVersion": "0.1.0", + "sha256": "17e6b6d5b0283af8471140aafbbfe3040aff7d16cc1c210ac3033bd0b9ce571c", + "size": 709 + }, + { + "path": "registry/ratelimit/README.md", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "67b61f88d28f4d61668c7413f9136d44ae8a253edaf690ca0b5f8b03169fa257", + "size": 1106 + }, + { + "path": "registry/ratelimit/__llms__.md", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "0c328bedacfde1fb0cb9c58884a5bb2e7d7c6ec6234b8dc1ef7eadf1b8c2d5f1", + "size": 438 + }, + { + "path": "registry/ratelimit/src/go/fixed_window.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "90e7546ef291548db57585b7a334b9399920781441447c158336f2ad55af4c80", + "size": 2687 + }, + { + "path": "registry/ratelimit/src/go/fixed_window_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "d215b84ee06f3dd09e13170bd4b2548aa7bae2b533bf5424fe8d87939fa74996", + "size": 999 + }, + { + "path": "registry/ratelimit/src/go/go.mod", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "5afc78bc6cba0332e5de423c8a85422c16430114956ad505dc1441932389edce", + "size": 26 + }, + { + "path": "registry/ratelimit/src/go/middleware.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "874d6ad9b6222f564e6b27a8d81bffd69d1920b135ecbc74f905f3daf2daf92d", + "size": 4520 + }, + { + "path": "registry/ratelimit/src/go/middleware_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "d8ee73177a089a5281aafb6e15365608b3c54241e5bf6db9b10722ab03d476c6", + "size": 23741 + }, + { + "path": "registry/ratelimit/src/go/pentest_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "901dda364b029033101e2bfd33f0b27c72b1817c164ce677f26bc9c4bdf3edc4", + "size": 23169 + }, + { + "path": "registry/ratelimit/src/go/sliding_window.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "c81526204ed7fbb27a131a660d2f6e2969c64df62b457251dbd7f7a23d6f89c1", + "size": 3234 + }, + { + "path": "registry/ratelimit/src/go/sliding_window_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "054ca8d1e53b616ae5adb4a13ecefd6bb2f66ad29cc862c92c0ae27ebfdb4a31", + "size": 984 + }, + { + "path": "registry/ratelimit/src/go/store.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "69a4e659d27e2685abcddf8c8fc5e7d5396a449d0542f6a454f9aabb28c50402", + "size": 5819 + }, + { + "path": "registry/ratelimit/src/go/store_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "e036820d70e170bc2c5e3e625e8d822d0859c8520367de1cf229c29104b53e65", + "size": 823 + }, + { + "path": "registry/ratelimit/src/go/token_bucket.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "e5f3c705aceaa215c0d3b0e577fb53a0b491f7276ee024b4c96d3c351e01a882", + "size": 3364 + }, + { + "path": "registry/ratelimit/src/go/token_bucket_test.go", + "module": "ratelimit", + "moduleVersion": "0.1.0", + "sha256": "cf248eba40d0abb14521313c185ce9435f0c08567173911205b9d0ebd35935ed", + "size": 945 + }, + { + "path": "registry/rbac/README.md", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "1bdc3ef97d64315a9c534dbc50e60019bb257d819a9bc29945877abf1657223f", + "size": 1148 + }, + { + "path": "registry/rbac/__llms__.md", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "167db13c2207732c2f927f5ac62f1e834c6bc0fff958a7eccd988db602ad9cc8", + "size": 383 + }, + { + "path": "registry/rbac/src/go/context.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "cf3a11ac69f12d8b3f6812bb969fc52f4e438a0415c5c388900851d3c5fc8c16", + "size": 1636 + }, + { + "path": "registry/rbac/src/go/context_test.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "a933c88e09f81f706d0081d52ba1bfe1676f26931ec004872dd1fc0730f26fdd", + "size": 671 + }, + { + "path": "registry/rbac/src/go/go.mod", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "f0b6bc734af9006fe80ac59efba7884f6fa5b8b77e8e4ea19478f0d0359bd440", + "size": 21 + }, + { + "path": "registry/rbac/src/go/manager.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "a3a73effe29751c7920589d1d9d97d28a3439a03df7d8abf1ff9ef579ef9ba8f", + "size": 7109 + }, + { + "path": "registry/rbac/src/go/manager_test.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "ebc41319f5aa024ff615a53ede9c5ba6178638080d378dbe86bbf16955feccd3", + "size": 6170 + }, + { + "path": "registry/rbac/src/go/middleware.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "63bb02c220d738c4a164a46b0024e8c2a08993f0f1c020314486f006ee46ae46", + "size": 3312 + }, + { + "path": "registry/rbac/src/go/middleware_test.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "e08db610d7117eff4d0a5ad3788a7efec24f8b3b197af6e72ecc2dd40c40cb27", + "size": 1045 + }, + { + "path": "registry/rbac/src/go/model.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "73ac891e034a0857b0ab8709299ac835dd4b8811f34a92b7fb6218984f7b06da", + "size": 1786 + }, + { + "path": "registry/rbac/src/go/model_test.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "7b1f77988fca9adc2f8669b4627867a9404893177db5c5507814299a6b487f87", + "size": 562 + }, + { + "path": "registry/rbac/src/go/pentest_test.go", + "module": "rbac", + "moduleVersion": "0.1.0", + "sha256": "341cb3bb886fe89f1e1d88e651ac682e520b635072742565625b3bc35e364db9", + "size": 6150 + }, + { + "path": "registry/validation/README.md", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "526f91e6d5a91fcb9719f2b975c5ce1cc8e4a1915aebeee0f116c7c9439610dd", + "size": 1130 + }, + { + "path": "registry/validation/__llms__.md", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "fd2d22746fedb87daf2212eab08ac5deea599a450f5a46c51965f9c78d3cf122", + "size": 395 + }, + { + "path": "registry/validation/src/go/errors.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "ca163e51cb8245d8a2f31d2f61a34a53cbdb08251a3d4ad11f1acdf21871ec09", + "size": 2428 + }, + { + "path": "registry/validation/src/go/errors_test.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "9aa78e2490f4b2a538f53a541385b90073fed758d3e316aeb4170f43c46f4d6b", + "size": 802 + }, + { + "path": "registry/validation/src/go/go.mod", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "e813a6646bee04a523c2a05640a056ea0f9d1361133159693e19efb0655c771b", + "size": 27 + }, + { + "path": "registry/validation/src/go/middleware.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "a4e87f5404a02edb6c09aa658e92c80b65b4b604db0011ce816f534a42164775", + "size": 3050 + }, + { + "path": "registry/validation/src/go/middleware_test.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "ce59dc04f632a49eea4ef118d7b37a0bb9a2cc00aa73710d0f2c6b9ab21f275c", + "size": 1488 + }, + { + "path": "registry/validation/src/go/pentest_test.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "789f7bb3742dabcb8f435047f3992f9deaaf09be50a3b1ed85b38a9910ae263b", + "size": 9434 + }, + { + "path": "registry/validation/src/go/rules.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "488ad7837e9184ebb98d3b5bf11988b6f2334145a4f8503dd6af9aa3999cc1e0", + "size": 4783 + }, + { + "path": "registry/validation/src/go/rules_test.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "5d43395eb424753d56efa684188cf1ba9e880a3a0277315a683403c11471b81c", + "size": 788 + }, + { + "path": "registry/validation/src/go/validator.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "48438a87638c19ce58f10a34f01d2964598a3b9aaae266ebfef1deb1c42a220b", + "size": 15236 + }, + { + "path": "registry/validation/src/go/validator_test.go", + "module": "validation", + "moduleVersion": "0.1.0", + "sha256": "f1681a414f4e2470d18cb91d54490c3aeba3ef97c5dbe57d30561c9bddd3f042", + "size": 17011 + } + ] +} diff --git a/internal/bundle/registry.zip b/internal/bundle/registry.zip new file mode 100644 index 0000000..860e6a8 Binary files /dev/null and b/internal/bundle/registry.zip differ diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..fc44c17 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,384 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + + "github.com/DarkInno/scion/internal/copytree" + "github.com/DarkInno/scion/internal/doctor" + "github.com/DarkInno/scion/internal/registry" + "github.com/DarkInno/scion/internal/version" +) + +type App struct { + reg *registry.Bundle +} + +func New(reg *registry.Bundle) *App { + return &App{reg: reg} +} + +func (a *App) Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { + _ = ctx + if len(args) == 0 { + writeUsage(stderr) + return 1 + } + + switch args[0] { + case "list": + return a.runList(args[1:], stdout, stderr) + case "info": + return a.runInfo(args[1:], stdout, stderr) + case "add": + return a.runAdd(args[1:], stdout, stderr) + case "diff": + return a.runDiff(args[1:], stdout, stderr) + case "doctor": + return a.runDoctor(args[1:], stdout, stderr) + case "version": + return a.runVersion(args[1:], stdout, stderr) + case "-h", "--help", "help": + writeUsage(stdout) + return 0 + default: + _, _ = fmt.Fprintf(stderr, "unknown command: %s\n\n", args[0]) + writeUsage(stderr) + return 1 + } +} + +func (a *App) runList(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("list", stderr) + jsonOut := fs.Bool("json", false, "write JSON") + if err := fs.Parse(args); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "list does not accept positional arguments\n") + return 1 + } + + modules := a.reg.Index.SortedModules() + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(modules) + return 0 + } + + tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(tw, "ID\tPACKAGE\tSTATUS\tSTDLIB\tDEFAULT TARGET\n") + for _, module := range modules { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%t\t%s\n", module.ID, module.Package, module.Status, module.StdlibOnly, module.DefaultTarget) + } + _ = tw.Flush() + return 0 +} + +func (a *App) runInfo(args []string, stdout, stderr io.Writer) int { + moduleID, rest, err := takeFirstPositional(args) + if err != nil { + _, _ = fmt.Fprintf(stderr, "info requires a module id\n") + return 1 + } + fs := newFlagSet("info", stderr) + jsonOut := fs.Bool("json", false, "write JSON") + if err := fs.Parse(rest); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "unexpected argument: %s\n", fs.Arg(0)) + return 1 + } + + module, ok := a.reg.Index.Module(moduleID) + if !ok { + _, _ = fmt.Fprintf(stderr, "unknown module: %s\n", moduleID) + return 1 + } + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(module) + return 0 + } + + _, _ = fmt.Fprintf(stdout, "ID: %s\n", module.ID) + _, _ = fmt.Fprintf(stdout, "Name: %s\n", module.Name) + _, _ = fmt.Fprintf(stdout, "Description: %s\n", module.Description) + _, _ = fmt.Fprintf(stdout, "Version: %s\n", module.Version) + _, _ = fmt.Fprintf(stdout, "Package: %s\n", module.Package) + _, _ = fmt.Fprintf(stdout, "Source: %s\n", module.Source) + _, _ = fmt.Fprintf(stdout, "Default target: %s\n", module.DefaultTarget) + _, _ = fmt.Fprintf(stdout, "Standard library only: %t\n", module.StdlibOnly) + _, _ = fmt.Fprintf(stdout, "Status: %s\n", module.Status) + return 0 +} + +func (a *App) runAdd(args []string, stdout, stderr io.Writer) int { + moduleID, rest, err := takeFirstPositional(args) + if err != nil { + _, _ = fmt.Fprintf(stderr, "add requires a module id\n") + return 1 + } + fs := newFlagSet("add", stderr) + target := fs.String("to", "", "destination directory") + dryRun := fs.Bool("dry-run", false, "show files without writing") + force := fs.Bool("force", false, "overwrite files copied by the bundle") + standalone := fs.Bool("standalone", false, "copy go.mod/go.sum as a standalone module") + if err := fs.Parse(rest); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "unexpected argument: %s\n", fs.Arg(0)) + return 1 + } + if strings.TrimSpace(*target) == "" { + _, _ = fmt.Fprintf(stderr, "add requires --to \n") + return 1 + } + + module, ok := a.reg.Index.Module(moduleID) + if !ok { + _, _ = fmt.Fprintf(stderr, "unknown module: %s\n", moduleID) + return 1 + } + if !module.StdlibOnly && !*standalone { + _, _ = fmt.Fprintf(stderr, "module %s is marked stdlibOnly=false; use --standalone to copy its go.mod/go.sum or choose a zero-dependency module\n", module.ID) + return 1 + } + + files, err := a.reg.ModuleFiles(module, *standalone) + if err != nil { + _, _ = fmt.Fprintf(stderr, "read module files: %v\n", err) + return 1 + } + + copyFiles := make([]copytree.File, 0, len(files)+1) + sourceHashes := make(map[string]string, len(files)) + copied := make([]string, 0, len(files)) + for _, file := range files { + data, err := a.reg.ReadFile(file.SourcePath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "read %s: %v\n", file.SourcePath, err) + return 1 + } + copyFiles = append(copyFiles, copytree.File{RelPath: file.RelativePath, Data: data, Mode: 0o644}) + sourceHashes[file.RelativePath] = file.SHA256 + copied = append(copied, file.RelativePath) + } + sort.Strings(copied) + meta := moduleMetadata{ + SchemaVersion: 1, + Module: module.ID, + ModuleVersion: module.Version, + RegistryVersion: a.reg.Index.Version, + CopiedFiles: copied, + SourceHashes: sourceHashes, + Standalone: *standalone, + } + metaData, err := json.MarshalIndent(meta, "", " ") + if err != nil { + _, _ = fmt.Fprintf(stderr, "build metadata: %v\n", err) + return 1 + } + metaData = append(metaData, '\n') + copyFiles = append(copyFiles, copytree.File{RelPath: metadataFile, Data: metaData, Mode: 0o644}) + + result, err := copytree.CopyFiles(*target, copyFiles, copytree.Options{DryRun: *dryRun, Force: *force}) + if err != nil { + _, _ = fmt.Fprintf(stderr, "copy failed: %v\n", err) + return 1 + } + + if *dryRun { + _, _ = fmt.Fprintf(stdout, "Would copy %d files for %s to %s\n", len(result.Files), module.ID, *target) + } else { + _, _ = fmt.Fprintf(stdout, "Copied %s %s to %s\n", module.ID, module.Version, *target) + } + for _, file := range result.Files { + _, _ = fmt.Fprintf(stdout, " %s\n", file) + } + return 0 +} + +func (a *App) runDiff(args []string, stdout, stderr io.Writer) int { + moduleID, rest, err := takeFirstPositional(args) + if err != nil { + _, _ = fmt.Fprintf(stderr, "diff requires a module id\n") + return 1 + } + fs := newFlagSet("diff", stderr) + target := fs.String("target", "", "destination directory") + jsonOut := fs.Bool("json", false, "write JSON") + if err := fs.Parse(rest); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "unexpected argument: %s\n", fs.Arg(0)) + return 1 + } + if strings.TrimSpace(*target) == "" { + _, _ = fmt.Fprintf(stderr, "diff requires --target \n") + return 1 + } + + module, ok := a.reg.Index.Module(moduleID) + if !ok { + _, _ = fmt.Fprintf(stderr, "unknown module: %s\n", moduleID) + return 1 + } + result, err := buildDiff(a.reg, module, filepath.Clean(*target)) + if err != nil { + _, _ = fmt.Fprintf(stderr, "diff failed: %v\n", err) + return 1 + } + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(result) + } else { + writeDiff(stdout, result) + } + if result.HasDifferences { + return 2 + } + return 0 +} + +func (a *App) runDoctor(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("doctor", stderr) + strict := fs.Bool("strict", false, "treat release-gate warnings as errors") + jsonOut := fs.Bool("json", false, "write JSON") + if err := fs.Parse(args); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "unexpected argument: %s\n", fs.Arg(0)) + return 1 + } + + cwd, err := os.Getwd() + if err != nil { + _, _ = fmt.Fprintf(stderr, "doctor failed: %v\n", err) + return 1 + } + report := doctor.Run(a.reg, cwd, *strict) + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(report) + } else { + writeDoctor(stdout, report) + } + if len(report.Issues) > 0 { + return 2 + } + return 0 +} + +func (a *App) runVersion(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("version", stderr) + jsonOut := fs.Bool("json", false, "write JSON") + if err := fs.Parse(args); err != nil { + return 1 + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintf(stderr, "unexpected argument: %s\n", fs.Arg(0)) + return 1 + } + + out := map[string]string{ + "version": version.Version, + "commit": version.Commit, + "registryVersion": a.reg.Index.Version, + "bundleHash": a.reg.Manifest.BundleHash, + } + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(out) + return 0 + } + _, _ = fmt.Fprintf(stdout, "scion %s\n", version.Version) + _, _ = fmt.Fprintf(stdout, "commit: %s\n", version.Commit) + _, _ = fmt.Fprintf(stdout, "registry: %s\n", a.reg.Index.Version) + _, _ = fmt.Fprintf(stdout, "bundle: %s\n", a.reg.Manifest.BundleHash) + return 0 +} + +func writeDiff(w io.Writer, result diffResult) { + if !result.HasDifferences { + _, _ = fmt.Fprintf(w, "%s matches embedded registry version %s\n", result.Module, result.ModuleVersion) + return + } + for _, warning := range result.MetadataWarnings { + _, _ = fmt.Fprintf(w, "warning: %s\n", warning) + } + writeFileGroup(w, "modified", result.ModifiedFiles) + writeFileGroup(w, "missing", result.MissingFiles) + writeFileGroup(w, "added", result.AddedFiles) + _, _ = fmt.Fprintf(w, "%d modified, %d missing, %d added file%s\n", + len(result.ModifiedFiles), + len(result.MissingFiles), + len(result.AddedFiles), + pluralSuffix(len(result.ModifiedFiles)+len(result.MissingFiles)+len(result.AddedFiles)), + ) +} + +func writeFileGroup(w io.Writer, label string, files []string) { + if len(files) == 0 { + return + } + _, _ = fmt.Fprintf(w, "%s:\n", label) + for _, file := range files { + _, _ = fmt.Fprintf(w, " %s\n", file) + } +} + +func writeDoctor(w io.Writer, report doctor.Report) { + if len(report.Issues) == 0 { + _, _ = fmt.Fprintf(w, "doctor ok\n") + return + } + for _, issue := range report.Issues { + if issue.Module != "" { + _, _ = fmt.Fprintf(w, "%s [%s]: %s\n", issue.Level, issue.Module, issue.Message) + } else { + _, _ = fmt.Fprintf(w, "%s: %s\n", issue.Level, issue.Message) + } + } + if report.OK { + _, _ = fmt.Fprintf(w, "doctor completed with warnings\n") + } else { + _, _ = fmt.Fprintf(w, "doctor found errors\n") + } +} + +func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + return fs +} + +func takeFirstPositional(args []string) (string, []string, error) { + for i, arg := range args { + if strings.HasPrefix(arg, "-") { + continue + } + rest := make([]string, 0, len(args)-1) + rest = append(rest, args[:i]...) + rest = append(rest, args[i+1:]...) + return arg, rest, nil + } + return "", nil, fmt.Errorf("missing positional argument") +} + +func writeUsage(w io.Writer) { + _, _ = fmt.Fprintf(w, "Usage:\n") + _, _ = fmt.Fprintf(w, " scion list [--json]\n") + _, _ = fmt.Fprintf(w, " scion info [--json]\n") + _, _ = fmt.Fprintf(w, " scion add --to [--dry-run] [--force] [--standalone]\n") + _, _ = fmt.Fprintf(w, " scion diff --target [--json]\n") + _, _ = fmt.Fprintf(w, " scion doctor [--strict] [--json]\n") + _, _ = fmt.Fprintf(w, " scion version [--json]\n") +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..aac89d8 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,96 @@ +package cli_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/DarkInno/scion/internal/bundle" + "github.com/DarkInno/scion/internal/cli" + "github.com/DarkInno/scion/internal/registry" +) + +func newTestApp(t *testing.T) *cli.App { + t.Helper() + reg, err := registry.NewBundle(bundle.RegistryZip, bundle.ManifestJSON) + if err != nil { + t.Fatalf("load embedded bundle: %v", err) + } + return cli.New(reg) +} + +func TestAddCacheAndDiffInTemporaryGoProject(t *testing.T) { + app := newTestApp(t) + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, "go.mod"), []byte("module example.com/app\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + target := filepath.Join(project, "internal", "cache") + + var stdout, stderr bytes.Buffer + code := app.Run(context.Background(), []string{"add", "cache", "--to", target}, &stdout, &stderr) + if code != 0 { + t.Fatalf("add exit %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(target, ".scion-module.json")); err != nil { + t.Fatalf("metadata missing: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "go.mod")); !os.IsNotExist(err) { + t.Fatalf("go.mod should not be copied by default, err=%v", err) + } + + cmd := exec.Command("go", "test", "./...") + cmd.Dir = project + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("copied cache package does not test cleanly: %v\n%s", err, string(out)) + } + + stdout.Reset() + stderr.Reset() + code = app.Run(context.Background(), []string{"diff", "cache", "--target", target}, &stdout, &stderr) + if code != 0 { + t.Fatalf("diff exit %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + + if err := os.WriteFile(filepath.Join(target, "local.txt"), []byte("user file"), 0o644); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + code = app.Run(context.Background(), []string{"diff", "cache", "--target", target}, &stdout, &stderr) + if code != 2 { + t.Fatalf("diff with added file exit %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "local.txt") { + t.Fatalf("diff output did not mention added file: %q", stdout.String()) + } +} + +func TestAddRefusesExternalDependencyModuleWithoutStandalone(t *testing.T) { + app := newTestApp(t) + var stdout, stderr bytes.Buffer + code := app.Run(context.Background(), []string{"add", "auth", "--to", filepath.Join(t.TempDir(), "internal", "auth")}, &stdout, &stderr) + if code != 1 { + t.Fatalf("add auth exit %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "stdlibOnly=false") { + t.Fatalf("expected stdlibOnly error, got %q", stderr.String()) + } +} + +func TestListJSON(t *testing.T) { + app := newTestApp(t) + var stdout, stderr bytes.Buffer + code := app.Run(context.Background(), []string{"list", "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list exit %d, stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id":"cache"`) { + t.Fatalf("list json missing cache: %q", stdout.String()) + } +} diff --git a/internal/cli/diff.go b/internal/cli/diff.go new file mode 100644 index 0000000..bba1696 --- /dev/null +++ b/internal/cli/diff.go @@ -0,0 +1,139 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sort" + + "github.com/DarkInno/scion/internal/copytree" + "github.com/DarkInno/scion/internal/registry" +) + +type diffResult struct { + Module string `json:"module"` + Target string `json:"target"` + ModuleVersion string `json:"moduleVersion"` + RegistryVersion string `json:"registryVersion"` + MetadataFound bool `json:"metadataFound"` + MetadataStandalone bool `json:"metadataStandalone"` + MetadataWarnings []string `json:"metadataWarnings,omitempty"` + ModifiedFiles []string `json:"modifiedFiles,omitempty"` + MissingFiles []string `json:"missingFiles,omitempty"` + AddedFiles []string `json:"addedFiles,omitempty"` + UnchangedFiles []string `json:"unchangedFiles,omitempty"` + HasDifferences bool `json:"hasDifferences"` +} + +func buildDiff(reg *registry.Bundle, module registry.Module, target string) (diffResult, error) { + targetAbs, err := filepath.Abs(target) + if err != nil { + return diffResult{}, err + } + targetAbs = filepath.Clean(targetAbs) + + meta, found, err := readMetadata(targetAbs) + if err != nil { + return diffResult{}, err + } + + standalone := false + var warnings []string + if found { + standalone = meta.Standalone + if meta.Module != "" && meta.Module != module.ID { + warnings = append(warnings, "metadata module does not match requested module") + } + if meta.ModuleVersion != "" && meta.ModuleVersion != module.Version { + warnings = append(warnings, "metadata module version differs from embedded registry") + } + if meta.RegistryVersion != "" && meta.RegistryVersion != reg.Index.Version { + warnings = append(warnings, "metadata registry version differs from embedded registry") + } + } else { + warnings = append(warnings, "metadata file not found") + } + + files, err := reg.ModuleFiles(module, standalone) + if err != nil { + return diffResult{}, err + } + + expected := make(map[string]registry.ModuleFile, len(files)) + for _, file := range files { + expected[file.RelativePath] = file + } + + result := diffResult{ + Module: module.ID, + Target: targetAbs, + ModuleVersion: module.Version, + RegistryVersion: reg.Index.Version, + MetadataFound: found, + MetadataStandalone: standalone, + MetadataWarnings: warnings, + } + + for _, file := range files { + targetFile, err := copytree.SafeJoin(targetAbs, file.RelativePath) + if err != nil { + return diffResult{}, err + } + data, err := os.ReadFile(targetFile) + if os.IsNotExist(err) { + result.MissingFiles = append(result.MissingFiles, file.RelativePath) + continue + } + if err != nil { + return diffResult{}, err + } + hash := sha256.Sum256(data) + if hex.EncodeToString(hash[:]) != file.SHA256 { + result.ModifiedFiles = append(result.ModifiedFiles, file.RelativePath) + } else { + result.UnchangedFiles = append(result.UnchangedFiles, file.RelativePath) + } + } + + if err := filepath.WalkDir(targetAbs, func(name string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(targetAbs, name) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == metadataFile { + return nil + } + if _, ok := expected[rel]; !ok { + result.AddedFiles = append(result.AddedFiles, rel) + } + return nil + }); err != nil && !os.IsNotExist(err) { + return diffResult{}, err + } + + sort.Strings(result.ModifiedFiles) + sort.Strings(result.MissingFiles) + sort.Strings(result.AddedFiles) + sort.Strings(result.UnchangedFiles) + sort.Strings(result.MetadataWarnings) + result.HasDifferences = len(result.ModifiedFiles) > 0 || + len(result.MissingFiles) > 0 || + len(result.AddedFiles) > 0 || + len(result.MetadataWarnings) > 0 + return result, nil +} + +func pluralSuffix(count int) string { + if count == 1 { + return "" + } + return "s" +} diff --git a/internal/cli/metadata.go b/internal/cli/metadata.go new file mode 100644 index 0000000..e08b913 --- /dev/null +++ b/internal/cli/metadata.go @@ -0,0 +1,34 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" +) + +const metadataFile = ".scion-module.json" + +type moduleMetadata struct { + SchemaVersion int `json:"schemaVersion"` + Module string `json:"module"` + ModuleVersion string `json:"moduleVersion"` + RegistryVersion string `json:"registryVersion"` + CopiedFiles []string `json:"copiedFiles"` + SourceHashes map[string]string `json:"sourceHashes"` + Standalone bool `json:"standalone"` +} + +func readMetadata(root string) (moduleMetadata, bool, error) { + data, err := os.ReadFile(filepath.Join(root, metadataFile)) + if os.IsNotExist(err) { + return moduleMetadata{}, false, nil + } + if err != nil { + return moduleMetadata{}, false, err + } + var meta moduleMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return moduleMetadata{}, false, err + } + return meta, true, nil +} diff --git a/internal/cmd/build-bundle/main.go b/internal/cmd/build-bundle/main.go new file mode 100644 index 0000000..f0c3e7a --- /dev/null +++ b/internal/cmd/build-bundle/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/DarkInno/scion/internal/registry" +) + +var fixedZipTime = time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC) + +func main() { + root := flag.String("root", ".", "repository root") + outDir := flag.String("out", "internal/bundle", "bundle output directory") + flag.Parse() + + if err := run(*root, *outDir); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "build-bundle: %v\n", err) + os.Exit(1) + } +} + +func run(root, outDir string) error { + rootAbs, err := filepath.Abs(root) + if err != nil { + return err + } + + indexBytes, err := os.ReadFile(filepath.Join(rootAbs, "registry", "index.json")) + if err != nil { + return err + } + idx, err := registry.ParseIndex(indexBytes) + if err != nil { + return err + } + + paths, err := collectRegistryFiles(rootAbs) + if err != nil { + return err + } + + manifest := registry.Manifest{ + SchemaVersion: 1, + RegistryVersion: idx.Version, + Modules: make([]registry.BundleModule, 0, len(idx.Patterns)), + Files: make([]registry.BundleFile, 0, len(paths)), + } + for _, module := range idx.SortedModules() { + manifest.Modules = append(manifest.Modules, registry.BundleModule{ + ID: module.ID, + Version: module.Version, + Source: module.Source, + }) + } + + moduleByPath := make([]registry.Module, 0, len(idx.Patterns)) + moduleByPath = append(moduleByPath, idx.Patterns...) + sort.Slice(moduleByPath, func(i, j int) bool { + return len(moduleByPath[i].Path) > len(moduleByPath[j].Path) + }) + + var zipData bytes.Buffer + zipWriter := zip.NewWriter(&zipData) + for _, rel := range paths { + abs := filepath.Join(rootAbs, filepath.FromSlash(rel)) + data, err := os.ReadFile(abs) + if err != nil { + return err + } + header := &zip.FileHeader{ + Name: rel, + Method: zip.Store, + Modified: fixedZipTime, + } + header.SetMode(0o644) + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + if _, err := writer.Write(data); err != nil { + return err + } + + sum := sha256.Sum256(data) + entry := registry.BundleFile{ + Path: rel, + SHA256: hex.EncodeToString(sum[:]), + Size: int64(len(data)), + } + if module, ok := moduleForPath(moduleByPath, rel); ok { + entry.Module = module.ID + entry.ModuleVersion = module.Version + } + manifest.Files = append(manifest.Files, entry) + } + if err := zipWriter.Close(); err != nil { + return err + } + + bundleHash := sha256.Sum256(zipData.Bytes()) + manifest.BundleHash = hex.EncodeToString(bundleHash[:]) + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + manifestBytes = append(manifestBytes, '\n') + + outAbs, err := filepath.Abs(filepath.Join(rootAbs, outDir)) + if err != nil { + return err + } + if err := os.MkdirAll(outAbs, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outAbs, "registry.zip"), zipData.Bytes(), 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outAbs, "manifest.json"), manifestBytes, 0o644); err != nil { + return err + } + return nil +} + +func collectRegistryFiles(root string) ([]string, error) { + registryRoot := filepath.Join(root, "registry") + var paths []string + if err := filepath.WalkDir(registryRoot, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, name) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if _, err := registry.CleanBundlePath(rel); err != nil { + return err + } + paths = append(paths, rel) + return nil + }); err != nil { + return nil, err + } + sort.Strings(paths) + return paths, nil +} + +func moduleForPath(modules []registry.Module, rel string) (registry.Module, bool) { + for _, module := range modules { + prefix := strings.TrimSuffix(module.Path, "/") + "/" + if rel == module.Path || strings.HasPrefix(rel, prefix) { + return module, true + } + } + return registry.Module{}, false +} diff --git a/internal/copytree/copytree.go b/internal/copytree/copytree.go new file mode 100644 index 0000000..5c50320 --- /dev/null +++ b/internal/copytree/copytree.go @@ -0,0 +1,142 @@ +package copytree + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +type File struct { + RelPath string + Data []byte + Mode os.FileMode +} + +type Options struct { + DryRun bool + Force bool +} + +type Result struct { + Files []string +} + +func CopyFiles(root string, files []File, opts Options) (Result, error) { + if strings.TrimSpace(root) == "" { + return Result{}, fmt.Errorf("target directory is required") + } + + rootAbs, err := filepath.Abs(root) + if err != nil { + return Result{}, err + } + rootAbs = filepath.Clean(rootAbs) + + targets := make([]struct { + file File + path string + }, 0, len(files)) + for _, file := range files { + targetPath, err := SafeJoin(rootAbs, file.RelPath) + if err != nil { + return Result{}, err + } + if info, err := os.Stat(targetPath); err == nil { + if info.IsDir() { + return Result{}, fmt.Errorf("target path is a directory: %s", file.RelPath) + } + if !opts.Force { + return Result{}, fmt.Errorf("target file already exists: %s", file.RelPath) + } + } else if !os.IsNotExist(err) { + return Result{}, err + } + targets = append(targets, struct { + file File + path string + }{file: file, path: targetPath}) + } + + var copied []string + for _, target := range targets { + copied = append(copied, normalizeRel(target.file.RelPath)) + if opts.DryRun { + continue + } + if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil { + return Result{}, err + } + mode := target.file.Mode + if mode == 0 { + mode = 0o644 + } + if err := os.WriteFile(target.path, target.file.Data, mode); err != nil { + return Result{}, err + } + } + + return Result{Files: copied}, nil +} + +func SafeJoin(rootAbs, rel string) (string, error) { + cleanRel, err := CleanRelativePath(rel) + if err != nil { + return "", err + } + + target := filepath.Join(rootAbs, filepath.FromSlash(cleanRel)) + targetAbs, err := filepath.Abs(target) + if err != nil { + return "", err + } + targetAbs = filepath.Clean(targetAbs) + + inside, err := isInside(rootAbs, targetAbs) + if err != nil { + return "", err + } + if !inside { + return "", fmt.Errorf("target escapes destination root: %s", rel) + } + return targetAbs, nil +} + +func CleanRelativePath(rel string) (string, error) { + if strings.ContainsAny(rel, "\x00\r\n") { + return "", fmt.Errorf("path contains control characters") + } + if strings.Contains(rel, "\\") { + return "", fmt.Errorf("path must use slash separators") + } + if path.IsAbs(rel) || filepath.IsAbs(rel) { + return "", fmt.Errorf("absolute path is not allowed") + } + clean := path.Clean(rel) + if clean == "." || clean == "" { + return "", fmt.Errorf("empty path") + } + for _, part := range strings.Split(clean, "/") { + if part == ".." || part == "" { + return "", fmt.Errorf("path traversal is not allowed") + } + } + return clean, nil +} + +func isInside(rootAbs, targetAbs string) (bool, error) { + rel, err := filepath.Rel(rootAbs, targetAbs) + if err != nil { + return false, err + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))), nil +} + +func normalizeRel(rel string) string { + clean, err := CleanRelativePath(rel) + if err != nil { + return rel + } + return clean +} diff --git a/internal/copytree/copytree_test.go b/internal/copytree/copytree_test.go new file mode 100644 index 0000000..04d41bd --- /dev/null +++ b/internal/copytree/copytree_test.go @@ -0,0 +1,71 @@ +package copytree + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCleanRelativePathRejectsUnsafePaths(t *testing.T) { + tests := []string{ + "", + ".", + "../secret", + "safe/../../secret", + "safe\\file.go", + "safe/\x00file.go", + "safe/\r\nfile.go", + } + if runtime.GOOS == "windows" { + tests = append(tests, `C:/secret/file.go`) + } else { + tests = append(tests, `/secret/file.go`) + } + + for _, test := range tests { + t.Run(test, func(t *testing.T) { + if _, err := CleanRelativePath(test); err == nil { + t.Fatalf("expected %q to be rejected", test) + } + }) + } +} + +func TestCopyFilesRefusesOverwriteUnlessForced(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cache.go"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := CopyFiles(dir, []File{{RelPath: "cache.go", Data: []byte("new")}}, Options{}) + if err == nil { + t.Fatal("expected overwrite without force to fail") + } + + if _, err := CopyFiles(dir, []File{{RelPath: "cache.go", Data: []byte("new")}}, Options{Force: true}); err != nil { + t.Fatalf("forced copy failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "cache.go")) + if err != nil { + t.Fatal(err) + } + if string(data) != "new" { + t.Fatalf("expected forced copy to replace file, got %q", string(data)) + } +} + +func TestSafeJoinRejectsEscape(t *testing.T) { + root, err := filepath.Abs(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := SafeJoin(root, "../outside.go"); err == nil { + t.Fatal("expected path escape to fail") + } + if got, err := SafeJoin(root, "internal/cache/cache.go"); err != nil { + t.Fatalf("safe path failed: %v", err) + } else if filepath.Dir(got) != filepath.Join(root, "internal", "cache") { + t.Fatalf("unexpected joined path: %s", got) + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go new file mode 100644 index 0000000..be0d1e3 --- /dev/null +++ b/internal/doctor/doctor.go @@ -0,0 +1,205 @@ +package doctor + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/DarkInno/scion/internal/registry" +) + +type Issue struct { + Level string `json:"level"` + Module string `json:"module,omitempty"` + Message string `json:"message"` +} + +type Report struct { + Strict bool `json:"strict"` + OK bool `json:"ok"` + Issues []Issue `json:"issues,omitempty"` +} + +func Run(reg *registry.Bundle, root string, strict bool) Report { + var issues []Issue + add := func(level, module, message string) { + issues = append(issues, Issue{Level: level, Module: module, Message: message}) + } + gated := func(module, message string) { + if strict { + add("error", module, message) + } else { + add("warning", module, message) + } + } + + if reg.Index.SchemaVersion != registry.IndexSchemaVersion { + add("error", "", "registry index schema is unsupported") + } + if reg.Index.Version == "" { + add("error", "", "registry version is empty") + } + + seen := make(map[string]bool) + for _, module := range reg.Index.SortedModules() { + if module.ID == "" { + add("error", "", "module id is empty") + continue + } + if seen[module.ID] { + add("error", module.ID, "duplicate module id") + } + seen[module.ID] = true + + if module.Path == "" || module.Source == "" { + add("error", module.ID, "module path/source is required") + continue + } + if len(reg.ListFiles(module.Path+"/")) == 0 { + add("error", module.ID, "module path does not exist in bundle") + } + if len(reg.ListFiles(module.Source+"/")) == 0 { + add("error", module.ID, "module source does not exist in bundle") + } + if module.Version == "" { + add("error", module.ID, "module version is empty") + } + if module.Package == "" { + add("error", module.ID, "module package is empty") + } + if module.DefaultTarget == "" { + add("error", module.ID, "module defaultTarget is empty") + } + if len(module.Include) == 0 { + add("error", module.ID, "module include list is empty") + } + if !reg.HasFile(module.Path + "/README.md") { + gated(module.ID, "README.md is missing") + } + if !reg.HasFile(module.Path + "/__llms__.md") { + gated(module.ID, "__llms__.md is missing") + } + if !reg.HasFile(module.Source + "/pentest_test.go") { + add("error", module.ID, "pentest_test.go is missing") + } + if hasExternalRequire(reg, module) && module.StdlibOnly { + gated(module.ID, "go.mod contains external dependencies but module is marked stdlibOnly") + } + if strict { + checkTestPairing(reg, module, add) + } + } + + checkManifestFreshness(reg, root, add) + + sort.SliceStable(issues, func(i, j int) bool { + if issues[i].Level != issues[j].Level { + return issues[i].Level < issues[j].Level + } + if issues[i].Module != issues[j].Module { + return issues[i].Module < issues[j].Module + } + return issues[i].Message < issues[j].Message + }) + + ok := true + for _, issue := range issues { + if issue.Level == "error" { + ok = false + break + } + } + return Report{Strict: strict, OK: ok, Issues: issues} +} + +func hasExternalRequire(reg *registry.Bundle, module registry.Module) bool { + data, err := reg.ReadFile(module.Source + "/go.mod") + if err != nil { + return false + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "require (" || strings.HasPrefix(line, "require ") { + return true + } + } + return false +} + +func checkTestPairing(reg *registry.Bundle, module registry.Module, add func(string, string, string)) { + files := reg.ListFiles(module.Source + "/") + sourceFiles := make(map[string]bool) + testFiles := make(map[string]bool) + for _, file := range files { + if !strings.HasSuffix(file, ".go") { + continue + } + base := filepath.Base(file) + if strings.HasSuffix(base, "_test.go") { + testFiles[strings.TrimSuffix(base, "_test.go")] = true + continue + } + sourceFiles[strings.TrimSuffix(base, ".go")] = true + } + for base := range sourceFiles { + if !testFiles[base] { + add("error", module.ID, base+".go has no corresponding _test.go") + } + } +} + +func checkManifestFreshness(reg *registry.Bundle, root string, add func(string, string, string)) { + if root == "" { + return + } + registryRoot := filepath.Join(root, "registry") + if _, err := os.Stat(registryRoot); err != nil { + return + } + + manifestFiles := make(map[string]registry.BundleFile) + for _, file := range reg.Manifest.Files { + if strings.HasPrefix(file.Path, "registry/") { + manifestFiles[file.Path] = file + } + } + seen := make(map[string]bool) + for path, file := range manifestFiles { + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + if err != nil { + add("error", file.Module, "bundle is stale: missing "+path) + continue + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != file.SHA256 { + add("error", file.Module, "bundle is stale: hash mismatch for "+path) + } + seen[path] = true + } + + _ = filepath.WalkDir(registryRoot, func(name string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, err := filepath.Rel(root, name) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !seen[rel] { + add("error", "", "bundle is stale: untracked "+rel) + } + return nil + }) + + zipPath := filepath.Join(root, "internal", "bundle", "registry.zip") + if data, err := os.ReadFile(zipPath); err == nil { + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != reg.Manifest.BundleHash { + add("error", "", "bundle zip hash does not match manifest") + } + } +} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go new file mode 100644 index 0000000..eb07df3 --- /dev/null +++ b/internal/doctor/doctor_test.go @@ -0,0 +1,25 @@ +package doctor_test + +import ( + "testing" + + "github.com/DarkInno/scion/internal/bundle" + "github.com/DarkInno/scion/internal/doctor" + "github.com/DarkInno/scion/internal/registry" +) + +func TestDoctorStrictPassesReleaseReadyRegistry(t *testing.T) { + reg, err := registry.NewBundle(bundle.RegistryZip, bundle.ManifestJSON) + if err != nil { + t.Fatalf("load embedded bundle: %v", err) + } + + report := doctor.Run(reg, t.TempDir(), false) + if !report.OK || len(report.Issues) != 0 { + t.Fatalf("non-strict doctor should pass: %+v", report.Issues) + } + report = doctor.Run(reg, t.TempDir(), true) + if !report.OK || len(report.Issues) != 0 { + t.Fatalf("strict doctor should pass: %+v", report.Issues) + } +} diff --git a/internal/registry/bundle.go b/internal/registry/bundle.go new file mode 100644 index 0000000..eab083d --- /dev/null +++ b/internal/registry/bundle.go @@ -0,0 +1,218 @@ +package registry + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "path" + "sort" + "strings" +) + +type Bundle struct { + Index *Index + Manifest Manifest + + files map[string]*zip.File + manifestFile map[string]BundleFile +} + +type ModuleFile struct { + SourcePath string + RelativePath string + SHA256 string + Size int64 +} + +func NewBundle(zipBytes, manifestBytes []byte) (*Bundle, error) { + var manifest Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + if manifest.SchemaVersion != 1 { + return nil, fmt.Errorf("unsupported bundle manifest schema %d", manifest.SchemaVersion) + } + + reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + return nil, fmt.Errorf("open registry bundle: %w", err) + } + + files := make(map[string]*zip.File, len(reader.File)) + for _, file := range reader.File { + clean, err := CleanBundlePath(file.Name) + if err != nil { + return nil, fmt.Errorf("unsafe bundle path %q: %w", file.Name, err) + } + files[clean] = file + } + + indexBytes, err := readZipFile(files, "registry/index.json") + if err != nil { + return nil, err + } + idx, err := ParseIndex(indexBytes) + if err != nil { + return nil, fmt.Errorf("parse registry index: %w", err) + } + + manifestFile := make(map[string]BundleFile, len(manifest.Files)) + for _, file := range manifest.Files { + manifestFile[file.Path] = file + } + + hash := sha256.Sum256(zipBytes) + if got := hex.EncodeToString(hash[:]); manifest.BundleHash != "" && got != manifest.BundleHash { + return nil, fmt.Errorf("bundle hash mismatch: manifest has %s, zip has %s", manifest.BundleHash, got) + } + + return &Bundle{ + Index: idx, + Manifest: manifest, + files: files, + manifestFile: manifestFile, + }, nil +} + +func (b *Bundle) ReadFile(name string) ([]byte, error) { + clean, err := CleanBundlePath(name) + if err != nil { + return nil, err + } + return readZipFile(b.files, clean) +} + +func (b *Bundle) HasFile(name string) bool { + clean, err := CleanBundlePath(name) + if err != nil { + return false + } + _, ok := b.files[clean] + return ok +} + +func (b *Bundle) ListFiles(prefix string) []string { + prefix = strings.TrimSuffix(path.Clean(prefix), "/") + if prefix == "." { + prefix = "" + } + if prefix != "" { + prefix += "/" + } + + var out []string + for name := range b.files { + if strings.HasPrefix(name, prefix) { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +func (b *Bundle) ModuleFiles(module Module, standalone bool) ([]ModuleFile, error) { + source, err := CleanBundlePath(module.Source) + if err != nil { + return nil, fmt.Errorf("invalid source for %s: %w", module.ID, err) + } + sourcePrefix := strings.TrimSuffix(source, "/") + "/" + + patterns := append([]string(nil), module.Include...) + if standalone { + patterns = append(patterns, module.StandaloneInclude...) + } + if len(patterns) == 0 { + patterns = []string{"*.go"} + } + + var out []ModuleFile + for _, name := range b.ListFiles(sourcePrefix) { + file := b.files[name] + if file.FileInfo().IsDir() { + continue + } + rel := strings.TrimPrefix(name, sourcePrefix) + if rel == "" || strings.Contains(rel, "/") && !matchesAny(patterns, rel) { + continue + } + if !matchesAny(patterns, rel) { + continue + } + + entry, ok := b.manifestFile[name] + if !ok { + return nil, fmt.Errorf("bundle manifest missing %s", name) + } + out = append(out, ModuleFile{ + SourcePath: name, + RelativePath: rel, + SHA256: entry.SHA256, + Size: entry.Size, + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i].RelativePath < out[j].RelativePath + }) + return out, nil +} + +func CleanBundlePath(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("empty path") + } + if strings.ContainsAny(name, "\x00\r\n") { + return "", fmt.Errorf("path contains control characters") + } + if strings.Contains(name, "\\") { + return "", fmt.Errorf("path must use slash separators") + } + if path.IsAbs(name) { + return "", fmt.Errorf("absolute path is not allowed") + } + clean := path.Clean(name) + if clean == "." || clean == "" { + return "", fmt.Errorf("empty path") + } + for _, part := range strings.Split(clean, "/") { + if part == ".." { + return "", fmt.Errorf("path traversal is not allowed") + } + } + return clean, nil +} + +func readZipFile(files map[string]*zip.File, name string) ([]byte, error) { + file, ok := files[name] + if !ok { + return nil, fmt.Errorf("bundle file not found: %s", name) + } + rc, err := file.Open() + if err != nil { + return nil, err + } + defer func() { _ = rc.Close() }() + return io.ReadAll(rc) +} + +func matchesAny(patterns []string, rel string) bool { + rel = path.Clean(rel) + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + cleanPattern := path.Clean(pattern) + if cleanPattern == rel { + return true + } + ok, err := path.Match(cleanPattern, rel) + if err == nil && ok { + return true + } + } + return false +} diff --git a/internal/registry/bundle_test.go b/internal/registry/bundle_test.go new file mode 100644 index 0000000..81bde5d --- /dev/null +++ b/internal/registry/bundle_test.go @@ -0,0 +1,67 @@ +package registry_test + +import ( + "testing" + + "github.com/DarkInno/scion/internal/bundle" + "github.com/DarkInno/scion/internal/registry" +) + +func TestEmbeddedBundleLoadsSchemaV1Index(t *testing.T) { + reg, err := registry.NewBundle(bundle.RegistryZip, bundle.ManifestJSON) + if err != nil { + t.Fatalf("load embedded bundle: %v", err) + } + if reg.Index.SchemaVersion != registry.IndexSchemaVersion { + t.Fatalf("schema = %d", reg.Index.SchemaVersion) + } + if _, ok := reg.Index.Module("cache"); !ok { + t.Fatal("cache module not found") + } + if reg.Manifest.BundleHash == "" { + t.Fatal("bundle hash is empty") + } +} + +func TestModuleFilesExcludeGoModUnlessStandalone(t *testing.T) { + reg, err := registry.NewBundle(bundle.RegistryZip, bundle.ManifestJSON) + if err != nil { + t.Fatalf("load embedded bundle: %v", err) + } + module, ok := reg.Index.Module("cache") + if !ok { + t.Fatal("cache module not found") + } + + files, err := reg.ModuleFiles(module, false) + if err != nil { + t.Fatalf("module files: %v", err) + } + for _, file := range files { + if file.RelativePath == "go.mod" { + t.Fatal("go.mod should not be copied by default") + } + } + + files, err = reg.ModuleFiles(module, true) + if err != nil { + t.Fatalf("standalone module files: %v", err) + } + foundGoMod := false + for _, file := range files { + if file.RelativePath == "go.mod" { + foundGoMod = true + } + } + if !foundGoMod { + t.Fatal("go.mod should be copied in standalone mode") + } +} + +func TestCleanBundlePathRejectsZipSlip(t *testing.T) { + for _, path := range []string{"../x", "/x", "a\\b", "a/\x00b"} { + if _, err := registry.CleanBundlePath(path); err == nil { + t.Fatalf("expected %q to be rejected", path) + } + } +} diff --git a/internal/registry/index.go b/internal/registry/index.go new file mode 100644 index 0000000..3a50029 --- /dev/null +++ b/internal/registry/index.go @@ -0,0 +1,67 @@ +package registry + +import ( + "encoding/json" + "errors" + "fmt" + "sort" +) + +const IndexSchemaVersion = 1 + +type Index struct { + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Patterns []Module `json:"patterns"` +} + +type Module struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` + Languages []string `json:"languages"` + Frameworks []string `json:"frameworks"` + Tags []string `json:"tags"` + Version string `json:"version"` + Package string `json:"package"` + Source string `json:"source"` + DefaultTarget string `json:"defaultTarget"` + StdlibOnly bool `json:"stdlibOnly"` + Include []string `json:"include"` + StandaloneInclude []string `json:"standaloneInclude"` + Status string `json:"status"` +} + +func ParseIndex(data []byte) (*Index, error) { + var idx Index + if err := json.Unmarshal(data, &idx); err != nil { + return nil, err + } + if idx.SchemaVersion != IndexSchemaVersion { + return nil, fmt.Errorf("unsupported registry index schema %d", idx.SchemaVersion) + } + if len(idx.Patterns) == 0 { + return nil, errors.New("registry index has no modules") + } + return &idx, nil +} + +func (idx *Index) Module(id string) (Module, bool) { + for _, module := range idx.Patterns { + if module.ID == id { + return module, true + } + } + return Module{}, false +} + +func (idx *Index) SortedModules() []Module { + modules := append([]Module(nil), idx.Patterns...) + sort.Slice(modules, func(i, j int) bool { + return modules[i].ID < modules[j].ID + }) + return modules +} diff --git a/internal/registry/manifest.go b/internal/registry/manifest.go new file mode 100644 index 0000000..12fd66a --- /dev/null +++ b/internal/registry/manifest.go @@ -0,0 +1,23 @@ +package registry + +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + RegistryVersion string `json:"registryVersion"` + BundleHash string `json:"bundleHash"` + Modules []BundleModule `json:"modules"` + Files []BundleFile `json:"files"` +} + +type BundleModule struct { + ID string `json:"id"` + Version string `json:"version"` + Source string `json:"source"` +} + +type BundleFile struct { + Path string `json:"path"` + Module string `json:"module,omitempty"` + ModuleVersion string `json:"moduleVersion,omitempty"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..448d21e --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,7 @@ +package version + +const ( + // Version and Commit are overridden by release builds with -ldflags. + Version = "0.1.0-dev" + Commit = "dev" +) diff --git a/registry/auth/__llms__.md b/registry/auth/__llms__.md index b470054..3252e43 100644 --- a/registry/auth/__llms__.md +++ b/registry/auth/__llms__.md @@ -36,7 +36,7 @@ JWT-based email/password authentication with optional OAuth2 (Google, GitHub). - Optional: customize `RoutePrefix` before `RegisterRoutes()` ## Deps (Go) -golang-jwt/jwt/v5, golang.org/x/crypto/bcrypt, joho/godotenv +github.com/golang-jwt/jwt/v5, golang.org/x/crypto/bcrypt ## Deps (Python) SQLAlchemy 2.0, python-jose, passlib diff --git a/registry/auth/examples/gin/main.go b/registry/auth/examples/gin/main.go index fff4c47..abf290d 100644 --- a/registry/auth/examples/gin/main.go +++ b/registry/auth/examples/gin/main.go @@ -1,11 +1,11 @@ +//go:build ignore + package main import ( "log" "net/http" "os" - - "github.com/joho/godotenv" ) // auth is the copied Scion auth module. @@ -19,8 +19,6 @@ import ( // 3. Run `go mod tidy` func main() { - _ = godotenv.Load() - if os.Getenv("JWT_SECRET") == "" { os.Setenv("JWT_SECRET", "this-is-a-32-character-secret-key-for-dev") } diff --git a/registry/auth/src/go/config.go b/registry/auth/src/go/config.go index e902e63..4b7bfce 100644 --- a/registry/auth/src/go/config.go +++ b/registry/auth/src/go/config.go @@ -5,8 +5,6 @@ import ( "os" "strconv" "time" - - "github.com/joho/godotenv" ) const ( @@ -31,8 +29,6 @@ type Config struct { } func LoadConfig() (*Config, error) { - _ = godotenv.Load() // optional: load .env file if present - secret := os.Getenv("JWT_SECRET") if secret == "" { return nil, fmt.Errorf("JWT_SECRET is required") diff --git a/registry/auth/src/go/go.mod b/registry/auth/src/go/go.mod index e1375bd..1ea47b9 100644 --- a/registry/auth/src/go/go.mod +++ b/registry/auth/src/go/go.mod @@ -4,6 +4,5 @@ go 1.22 require ( github.com/golang-jwt/jwt/v5 v5.2.0 - github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.21.0 ) diff --git a/registry/auth/src/go/go.sum b/registry/auth/src/go/go.sum index 11a848d..eaf86a3 100644 --- a/registry/auth/src/go/go.sum +++ b/registry/auth/src/go/go.sum @@ -1,6 +1,4 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= diff --git a/registry/cache/README.md b/registry/cache/README.md new file mode 100644 index 0000000..911b99d --- /dev/null +++ b/registry/cache/README.md @@ -0,0 +1,50 @@ +# Cache Module + +Generic TTL + LRU in-memory cache with a small `Store[V]` interface. + +## What's Included + +- Type-safe generic cache values +- TTL expiration and background cleanup +- LRU eviction with a configurable max entry cap +- Atomic integer counters +- Key validation for CRLF, null bytes, empty strings, and length limits + +## Quick Copy + +```bash +cp -r registry/cache/src/go/*.go yourproject/internal/cache/ +``` + +Or with the Scion CLI: + +```bash +scion add cache --to internal/cache +``` + +## Usage + +```go +c := cache.New[string](cache.WithMaxEntries(1000)) +defer c.Close() + +_ = c.Set(ctx, "user:123", "Ada", 5*time.Minute) +value, ok := c.Get(ctx, "user:123") +_ = value +_ = ok +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `store.go` | Store interface, key validation, entry and LRU primitives | +| `memory.go` | Concurrency-safe in-memory implementation | +| `pentest_test.go` | Security and abuse-case tests | + +## Tests + +```bash +cd registry/cache/src/go +go test -v ./... +``` diff --git a/registry/cache/__llms__.md b/registry/cache/__llms__.md new file mode 100644 index 0000000..91b82bb --- /dev/null +++ b/registry/cache/__llms__.md @@ -0,0 +1,3 @@ +# Cache + +Zero-dependency Go cache module. Copy `src/go/*.go` into `internal/cache`; do not import Scion as a library. Provides generic `Store[V]` plus `MemoryCache[V]` with TTL expiration, LRU eviction, max entry bounds, counters, and clean shutdown. Keys reject empty strings, strings over 256 bytes, CRLF, and null bytes. Always call `Close()` on long-lived caches to stop the cleanup goroutine. diff --git a/registry/cache/src/go/store_test.go b/registry/cache/src/go/store_test.go new file mode 100644 index 0000000..a452152 --- /dev/null +++ b/registry/cache/src/go/store_test.go @@ -0,0 +1,51 @@ +package cache + +import ( + "strings" + "testing" + "time" +) + +func TestStoreValidateKeyRejectsUnsafeInput(t *testing.T) { + tests := map[string]error{ + "": ErrEmptyKey, + strings.Repeat("a", maxKeyLength+1): ErrKeyTooLong, + "bad\r\nkey": ErrInvalidKey, + "bad\x00key": ErrInvalidKey, + } + for key, want := range tests { + if got := validateKey(key); got != want { + t.Fatalf("validateKey(%q) = %v, want %v", key, got, want) + } + } + if err := validateKey("safe-key"); err != nil { + t.Fatalf("safe key rejected: %v", err) + } +} + +func TestStoreEntryAndLRUList(t *testing.T) { + if (Entry[string]{Value: "ok"}).Expired() { + t.Fatal("entry without expiration should not expire") + } + expired := Entry[string]{Expiration: time.Now().Add(-time.Second).UnixNano()} + if !expired.Expired() { + t.Fatal("past expiration should be expired") + } + + l := newLRUList[string]() + a := &lruNode[string]{key: "a"} + b := &lruNode[string]{key: "b"} + l.pushFront(a) + l.pushFront(b) + if l.back() != a { + t.Fatal("least-recently-used node should be a") + } + l.moveFront(a) + if l.back() != b { + t.Fatal("moveFront should make b the tail") + } + l.remove(a) + if l.head != b || l.tail != b { + t.Fatal("remove should leave b as only node") + } +} diff --git a/registry/crud/__llms__.md b/registry/crud/__llms__.md index 19469fc..0219a58 100644 --- a/registry/crud/__llms__.md +++ b/registry/crud/__llms__.md @@ -22,7 +22,7 @@ Generic CRUD endpoints with pagination, filtering, and sorting. - src/routes.py — register CRUD routes with your model ## Deps (Go) -joho/godotenv +Go standard library only ## Deps (Python) SQLAlchemy 2.0, Pydantic diff --git a/registry/crud/examples/gin/main.go b/registry/crud/examples/gin/main.go index b5aa35d..037bb77 100644 --- a/registry/crud/examples/gin/main.go +++ b/registry/crud/examples/gin/main.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( @@ -5,8 +7,6 @@ import ( "net/http" "os" "time" - - "github.com/joho/godotenv" ) // Product represents a sample entity. @@ -70,8 +70,6 @@ func (s *InMemoryStore) Delete(id uint) error { } func main() { - _ = godotenv.Load() - if os.Getenv("DB_URL") == "" { os.Setenv("DB_URL", "sqlite://dev.db") } diff --git a/registry/crud/src/go/config.go b/registry/crud/src/go/config.go index 1942521..e6fee3c 100644 --- a/registry/crud/src/go/config.go +++ b/registry/crud/src/go/config.go @@ -4,8 +4,6 @@ import ( "fmt" "os" "strconv" - - "github.com/joho/godotenv" ) // Config holds CRUD-related configuration. @@ -16,8 +14,6 @@ type Config struct { } func LoadConfig() (*Config, error) { - _ = godotenv.Load() - dbURL := os.Getenv("DB_URL") if dbURL == "" { return nil, fmt.Errorf("DB_URL is required") diff --git a/registry/crud/src/go/go.mod b/registry/crud/src/go/go.mod index 4b7e7a9..54261e4 100644 --- a/registry/crud/src/go/go.mod +++ b/registry/crud/src/go/go.mod @@ -1,5 +1,3 @@ module crud go 1.22 - -require github.com/joho/godotenv v1.5.1 diff --git a/registry/crud/src/go/go.sum b/registry/crud/src/go/go.sum deleted file mode 100644 index d61b19e..0000000 --- a/registry/crud/src/go/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/registry/file-upload/README.md b/registry/file-upload/README.md new file mode 100644 index 0000000..ab6d93b --- /dev/null +++ b/registry/file-upload/README.md @@ -0,0 +1,56 @@ +# File Upload Module + +Secure file upload handling with magic-byte validation and storage adapters. + +## What's Included + +- Standard `net/http` upload handler +- File size limits +- Magic-byte MIME detection +- Local and in-memory storage implementations +- Generated server-side file names +- Path traversal, CRLF, and null-byte protection +- Per-client in-memory rate limiting + +## Quick Copy + +```bash +cp -r registry/file-upload/src/go/*.go yourproject/internal/fileupload/ +``` + +Or with the Scion CLI: + +```bash +scion add file-upload --to internal/fileupload +``` + +## Usage + +```go +opts := fileupload.Defaults() +opts.UploadDir = "./uploads" +opts.AllowedTypes = []string{"image/png", "image/jpeg"} + +handler, err := fileupload.NewHandler(opts) +if err != nil { + return err +} +http.Handle("/upload", handler) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `config.go` | Defaults, environment loading, generated names | +| `handler.go` | HTTP upload handler and rate limiting | +| `storage.go` | Storage interface, local disk and memory storage | +| `validate.go` | Magic-byte detection and filename sanitization | +| `pentest_test.go` | Attack-scenario tests | + +## Tests + +```bash +cd registry/file-upload/src/go +go test -v ./... +``` diff --git a/registry/file-upload/__llms__.md b/registry/file-upload/__llms__.md new file mode 100644 index 0000000..4597e69 --- /dev/null +++ b/registry/file-upload/__llms__.md @@ -0,0 +1,3 @@ +# File Upload + +Zero-dependency Go file upload module. Copy `src/go/*.go` into `internal/fileupload`. Uses `net/http`, validates magic bytes instead of trusting extensions or Content-Type, generates server-side filenames, and stores through `Storage`. `LocalStorage` rejects separators, `..`, CRLF, and null bytes, then verifies the resolved path stays inside the root. Defaults allow common images and PDF with a 10 MiB limit. diff --git a/registry/file-upload/src/go/config_test.go b/registry/file-upload/src/go/config_test.go new file mode 100644 index 0000000..6cc1339 --- /dev/null +++ b/registry/file-upload/src/go/config_test.go @@ -0,0 +1,45 @@ +package fileupload + +import ( + "strings" + "testing" + "time" +) + +func TestConfigDefaultsAndFromEnv(t *testing.T) { + defaults := Defaults() + if defaults.MaxFileSize != DefaultMaxFileSize { + t.Fatalf("MaxFileSize = %d", defaults.MaxFileSize) + } + if defaults.FilenameFunc == nil { + t.Fatal("FilenameFunc should be set") + } + + t.Setenv("FILEUPLOAD_MAX_FILE_SIZE", "1024") + t.Setenv("FILEUPLOAD_RATE_LIMIT", "7") + t.Setenv("FILEUPLOAD_RATE_WINDOW", "2m") + t.Setenv("FILEUPLOAD_UPLOAD_DIR", "/tmp/uploads") + t.Setenv("FILEUPLOAD_URL_PREFIX", "/assets") + t.Setenv("FILEUPLOAD_ALLOWED_TYPES", "image/png, application/pdf") + + opts := FromEnv() + if opts.MaxFileSize != 1024 || opts.RateLimit != 7 || opts.RateWindow != 2*time.Minute { + t.Fatalf("env options not applied: %+v", opts) + } + if opts.UploadDir != "/tmp/uploads" || opts.URLPrefix != "/assets" { + t.Fatalf("env paths not applied: %+v", opts) + } + if got := strings.Join(opts.AllowedTypes, ","); got != "image/png,application/pdf" { + t.Fatalf("allowed types = %q", got) + } +} + +func TestConfigGeneratedNameIsUUIDLike(t *testing.T) { + name, err := generateUUIDv7() + if err != nil { + t.Fatalf("generateUUIDv7: %v", err) + } + if len(name) != 36 || name[14] != '7' { + t.Fatalf("unexpected UUIDv7: %q", name) + } +} diff --git a/registry/file-upload/src/go/storage_test.go b/registry/file-upload/src/go/storage_test.go new file mode 100644 index 0000000..e561137 --- /dev/null +++ b/registry/file-upload/src/go/storage_test.go @@ -0,0 +1,51 @@ +package fileupload + +import ( + "bytes" + "context" + "errors" + "testing" +) + +func TestStorageSafeNameRejectsTraversal(t *testing.T) { + for _, name := range []string{"", "../x", "a/b", `a\b`, "x\x00y", "x\r\ny"} { + if err := safeName(name); !errors.Is(err, ErrInvalidName) { + t.Fatalf("safeName(%q) = %v, want ErrInvalidName", name, err) + } + } +} + +func TestStorageMemoryCopiesData(t *testing.T) { + ctx := context.Background() + storage := NewMemoryStorage("/files") + data := []byte("hello") + url, err := storage.Save(ctx, "hello.txt", data) + if err != nil { + t.Fatalf("Save: %v", err) + } + if url != "/files/hello.txt" { + t.Fatalf("url = %q", url) + } + data[0] = 'x' + got, err := storage.Get(ctx, "hello.txt") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !bytes.Equal(got, []byte("hello")) { + t.Fatalf("stored data mutated: %q", got) + } + got[0] = 'x' + again, _ := storage.Get(ctx, "hello.txt") + if !bytes.Equal(again, []byte("hello")) { + t.Fatalf("Get should return a copy: %q", again) + } + if !storage.Exists(ctx, "hello.txt") { + t.Fatal("Exists should report saved file") + } + if err := storage.Delete(ctx, "hello.txt"); err != nil { + t.Fatalf("Delete: %v", err) + } + if storage.Exists(ctx, "hello.txt") { + t.Fatal("file should not exist after delete") + } +} diff --git a/registry/file-upload/src/go/validate_test.go b/registry/file-upload/src/go/validate_test.go new file mode 100644 index 0000000..d82c649 --- /dev/null +++ b/registry/file-upload/src/go/validate_test.go @@ -0,0 +1,34 @@ +package fileupload + +import ( + "errors" + "testing" +) + +func TestValidateContentDetectsMagicBytes(t *testing.T) { + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + ft, err := ValidateContent(png, []string{"image/png"}) + if err != nil { + t.Fatalf("ValidateContent: %v", err) + } + if ft.MIME != "image/png" || ft.Extension != ".png" { + t.Fatalf("unexpected type: %+v", ft) + } + + if _, err := ValidateContent([]byte("%PDF-1.7"), []string{"image/png"}); !errors.Is(err, ErrTypeNotAllowed) { + t.Fatalf("PDF should be rejected by whitelist: %v", err) + } + if _, err := ValidateContent(nil, []string{"image/png"}); !errors.Is(err, ErrEmptyFile) { + t.Fatalf("empty file = %v", err) + } + if _, err := ValidateContent([]byte("plain"), []string{"image/png"}); !errors.Is(err, ErrUnknownType) { + t.Fatalf("unknown file = %v", err) + } +} + +func TestValidateSanitizeFilename(t *testing.T) { + got := SanitizeFilename("../bad\r\nname\x00.png") + if got != "badname.png" { + t.Fatalf("sanitized name = %q", got) + } +} diff --git a/registry/health/README.md b/registry/health/README.md new file mode 100644 index 0000000..ff997c2 --- /dev/null +++ b/registry/health/README.md @@ -0,0 +1,54 @@ +# Health Module + +Liveness, readiness, and health probes for `net/http` services. + +## What's Included + +- Health checker registry +- Liveness, readiness, and combined health HTTP handlers +- Custom checks +- HTTP checks with SSRF protection +- TCP checks +- CRLF, null-byte, name, and URL length validation + +## Quick Copy + +```bash +cp -r registry/health/src/go/*.go yourproject/internal/health/ +``` + +Or with the Scion CLI: + +```bash +scion add health --to internal/health +``` + +## Usage + +```go +checker := health.New() +check, _ := health.NewCustomCheck("database", func(ctx context.Context) error { + return db.PingContext(ctx) +}) +_ = checker.AddCheck(check) + +h := health.NewHealthHandler(checker) +http.HandleFunc("/live", h.Liveness) +http.HandleFunc("/ready", h.Readiness) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `checker.go` | Check registration and execution | +| `checks.go` | HTTP, TCP, and custom check implementations | +| `handler.go` | JSON HTTP handlers | +| `pentest_test.go` | SSRF and injection tests | + +## Tests + +```bash +cd registry/health/src/go +go test -v ./... +``` diff --git a/registry/health/__llms__.md b/registry/health/__llms__.md new file mode 100644 index 0000000..f1bfb97 --- /dev/null +++ b/registry/health/__llms__.md @@ -0,0 +1,3 @@ +# Health + +Zero-dependency Go health probe module. Copy `src/go/*.go` into `internal/health`. Provides `HealthChecker`, `HealthHandler`, `NewCustomCheck`, `NewTCPCheck`, and `NewHTTPCheck`. HTTP checks block localhost, loopback, private, link-local, unspecified, and multicast IPs unless explicitly allow-listed; dial-time validation reduces DNS rebinding risk. Handlers emit JSON and ignore encoder errors with `_ =`. diff --git a/registry/health/src/go/checks_test.go b/registry/health/src/go/checks_test.go new file mode 100644 index 0000000..92c3461 --- /dev/null +++ b/registry/health/src/go/checks_test.go @@ -0,0 +1,47 @@ +package health + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestChecksValidateConstruction(t *testing.T) { + if _, err := NewHTTPCheck("local", "http://127.0.0.1"); err == nil || !strings.Contains(err.Error(), "ssrf blocked") { + t.Fatalf("expected SSRF rejection, got %v", err) + } + if _, err := NewTCPCheck("bad\r\nname", "127.0.0.1:80"); err == nil { + t.Fatal("expected CRLF check name rejection") + } + if _, err := NewCustomCheck("nil", nil); err == nil { + t.Fatal("expected nil custom function rejection") + } +} + +func TestChecksCustomExecute(t *testing.T) { + check, err := NewCustomCheck("custom", func(ctx context.Context) error { + return errors.New("down") + }) + if err != nil { + t.Fatalf("NewCustomCheck: %v", err) + } + result := check.Execute(context.Background()) + if result.Status != StatusFail || result.Error != "down" { + t.Fatalf("unexpected result: %+v", result) + } + + okCheck, _ := NewCustomCheck("ok", func(ctx context.Context) error { return nil }) + if got := okCheck.Execute(context.Background()); got.Status != StatusPass { + t.Fatalf("success result = %+v", got) + } + + tcp, err := NewTCPCheck("tcp", "127.0.0.1:1", WithTCPTimeout(time.Millisecond)) + if err != nil { + t.Fatalf("NewTCPCheck: %v", err) + } + if tcp.Name() != "tcp" { + t.Fatalf("tcp name = %q", tcp.Name()) + } +} diff --git a/registry/health/src/go/handler_test.go b/registry/health/src/go/handler_test.go new file mode 100644 index 0000000..d806c12 --- /dev/null +++ b/registry/health/src/go/handler_test.go @@ -0,0 +1,36 @@ +package health + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandlerLivenessAndReadiness(t *testing.T) { + checker := New(WithCacheTTL(0)) + fail, err := NewCustomCheck("database", func(ctx context.Context) error { + return errors.New("down") + }) + if err != nil { + t.Fatalf("NewCustomCheck: %v", err) + } + if err := checker.AddCheck(fail); err != nil { + t.Fatalf("AddCheck: %v", err) + } + handler := NewHealthHandler(checker) + + live := httptest.NewRecorder() + handler.Liveness(live, httptest.NewRequest(http.MethodGet, "/live", nil)) + if live.Code != http.StatusOK || !strings.Contains(live.Body.String(), StatusHealthy) { + t.Fatalf("liveness response: code=%d body=%q", live.Code, live.Body.String()) + } + + ready := httptest.NewRecorder() + handler.Readiness(ready, httptest.NewRequest(http.MethodGet, "/ready", nil)) + if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), StatusNotReady) { + t.Fatalf("readiness response: code=%d body=%q", ready.Code, ready.Body.String()) + } +} diff --git a/registry/index.json b/registry/index.json index 6d559a1..2905080 100644 --- a/registry/index.json +++ b/registry/index.json @@ -1,4 +1,5 @@ -{ +{ + "schemaVersion": 1, "name": "Scion", "version": "0.1.0", "description": "Backend pattern registry", @@ -10,7 +11,15 @@ "path": "registry/auth", "languages": ["go"], "frameworks": ["gin", "echo", "net/http"], - "tags": ["auth", "jwt", "bcrypt", "security", "rate-limiting"] + "tags": ["auth", "jwt", "bcrypt", "security", "rate-limiting"], + "version": "0.1.0", + "package": "auth", + "source": "registry/auth/src/go", + "defaultTarget": "internal/auth", + "stdlibOnly": false, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "external-deps" }, { "id": "crud", @@ -19,7 +28,15 @@ "path": "registry/crud", "languages": ["go"], "frameworks": ["gin", "echo", "net/http"], - "tags": ["crud", "api", "database", "pagination"] + "tags": ["crud", "api", "database", "pagination"], + "version": "0.1.0", + "package": "crud", + "source": "registry/crud/src/go", + "defaultTarget": "internal/crud", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "middleware", @@ -28,7 +45,15 @@ "path": "registry/middleware", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["middleware", "cors", "logging", "recovery", "timeout", "tracing", "security"] + "tags": ["middleware", "cors", "logging", "recovery", "timeout", "tracing", "security"], + "version": "0.1.0", + "package": "middleware", + "source": "registry/middleware/src/go", + "defaultTarget": "internal/middleware", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "rbac", @@ -37,7 +62,15 @@ "path": "registry/rbac", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["rbac", "authorization", "permissions", "security", "middleware"] + "tags": ["rbac", "authorization", "permissions", "security", "middleware"], + "version": "0.1.0", + "package": "rbac", + "source": "registry/rbac/src/go", + "defaultTarget": "internal/rbac", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "ratelimit", @@ -46,7 +79,15 @@ "path": "registry/ratelimit", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["rate-limiting", "middleware", "security", "token-bucket", "sliding-window"] + "tags": ["rate-limiting", "middleware", "security", "token-bucket", "sliding-window"], + "version": "0.1.0", + "package": "ratelimit", + "source": "registry/ratelimit/src/go", + "defaultTarget": "internal/ratelimit", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "validation", @@ -55,7 +96,15 @@ "path": "registry/validation", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["validation", "security", "middleware", "input-sanitization"] + "tags": ["validation", "security", "middleware", "input-sanitization"], + "version": "0.1.0", + "package": "validation", + "source": "registry/validation/src/go", + "defaultTarget": "internal/validation", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "file-upload", @@ -64,7 +113,15 @@ "path": "registry/file-upload", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["upload", "files", "security", "magic-bytes", "mime-validation"] + "tags": ["upload", "files", "security", "magic-bytes", "mime-validation"], + "version": "0.1.0", + "package": "fileupload", + "source": "registry/file-upload/src/go", + "defaultTarget": "internal/fileupload", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "health", @@ -73,7 +130,15 @@ "path": "registry/health", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["health", "readiness", "liveness", "monitoring", "ssrf-protection"] + "tags": ["health", "readiness", "liveness", "monitoring", "ssrf-protection"], + "version": "0.1.0", + "package": "health", + "source": "registry/health/src/go", + "defaultTarget": "internal/health", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "cache", @@ -82,7 +147,15 @@ "path": "registry/cache", "languages": ["go"], "frameworks": [], - "tags": ["cache", "lru", "ttl", "performance"] + "tags": ["cache", "lru", "ttl", "performance"], + "version": "0.1.0", + "package": "cache", + "source": "registry/cache/src/go", + "defaultTarget": "internal/cache", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "pagination", @@ -91,7 +164,15 @@ "path": "registry/pagination", "languages": ["go"], "frameworks": ["net/http", "gin", "echo"], - "tags": ["pagination", "cursor", "offset", "api"] + "tags": ["pagination", "cursor", "offset", "api"], + "version": "0.1.0", + "package": "pagination", + "source": "registry/pagination/src/go", + "defaultTarget": "internal/pagination", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" }, { "id": "mail", @@ -100,7 +181,15 @@ "path": "registry/mail", "languages": ["go"], "frameworks": [], - "tags": ["email", "smtp", "templates", "tls", "security"] + "tags": ["email", "smtp", "templates", "tls", "security"], + "version": "0.1.0", + "package": "mail", + "source": "registry/mail/src/go", + "defaultTarget": "internal/mail", + "stdlibOnly": true, + "include": ["*.go"], + "standaloneInclude": ["go.mod", "go.sum"], + "status": "ready" } ] } diff --git a/registry/mail/README.md b/registry/mail/README.md new file mode 100644 index 0000000..f48a8f0 --- /dev/null +++ b/registry/mail/README.md @@ -0,0 +1,61 @@ +# Mail Module + +SMTP email sender with message validation, templates, attachments, and async sending. + +## What's Included + +- SMTP sender using the Go standard library +- Plain text and HTML body support +- HTML template rendering +- Attachment path sanitization +- Header injection protection +- Optional async queue with workers + +## Quick Copy + +```bash +cp -r registry/mail/src/go/*.go yourproject/internal/mail/ +``` + +Or with the Scion CLI: + +```bash +scion add mail --to internal/mail +``` + +## Usage + +```go +sender, err := mail.NewSender(mail.Options{ + Host: "smtp.example.com", + Port: 587, + From: "noreply@example.com", +}) +if err != nil { + return err +} +defer sender.Close() + +err = sender.Send(&mail.Message{ + To: []string{"user@example.com"}, + Subject: "Welcome", + Body: "Welcome!", +}) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `config.go` | Options, defaults, environment loading | +| `message.go` | Message and attachment validation | +| `sender.go` | SMTP and async queue implementation | +| `template.go` | HTML template rendering | +| `pentest_test.go` | Header and attachment abuse tests | + +## Tests + +```bash +cd registry/mail/src/go +go test -v ./... +``` diff --git a/registry/mail/__llms__.md b/registry/mail/__llms__.md new file mode 100644 index 0000000..1faa5d4 --- /dev/null +++ b/registry/mail/__llms__.md @@ -0,0 +1,3 @@ +# Mail + +Zero-dependency Go SMTP mail module. Copy `src/go/*.go` into `internal/mail`. Configure with `Options`, `Defaults`, or `FromEnv`; use `NewSender`, `Send`, optional `SendAsync`, and `Close`. Validates addresses, sender, subject, text/html body sizes, attachment count/size/name/path, and rejects CRLF or null bytes to prevent header injection and traversal. diff --git a/registry/mail/src/go/config_test.go b/registry/mail/src/go/config_test.go new file mode 100644 index 0000000..083ac48 --- /dev/null +++ b/registry/mail/src/go/config_test.go @@ -0,0 +1,35 @@ +package mail + +import ( + "testing" + "time" +) + +func TestConfigDefaultsFromEnvAndValidate(t *testing.T) { + opts := Defaults() + if opts.Port != 587 || opts.Timeout != 10*time.Second || !opts.UseSTARTTLS { + t.Fatalf("unexpected defaults: %+v", opts) + } + if err := opts.Validate(); err == nil { + t.Fatal("missing host/from should fail") + } + + t.Setenv("SMTP_HOST", "smtp.example.com") + t.Setenv("SMTP_PORT", "2525") + t.Setenv("SMTP_FROM", "noreply@example.com") + t.Setenv("SMTP_TIMEOUT", "2s") + t.Setenv("SMTP_QUEUE_SIZE", "3") + t.Setenv("SMTP_USE_STARTTLS", "false") + t.Setenv("SMTP_USE_TLS", "true") + + opts = FromEnv() + if err := opts.Validate(); err != nil { + t.Fatalf("env options should validate: %v", err) + } + if opts.Port != 2525 || opts.Timeout != 2*time.Second || opts.QueueSize != 3 { + t.Fatalf("env numeric options not applied: %+v", opts) + } + if opts.UseSTARTTLS || !opts.UseTLS { + t.Fatalf("TLS options not applied: %+v", opts) + } +} diff --git a/registry/mail/src/go/message_test.go b/registry/mail/src/go/message_test.go new file mode 100644 index 0000000..ce67d2b --- /dev/null +++ b/registry/mail/src/go/message_test.go @@ -0,0 +1,34 @@ +package mail + +import ( + "strings" + "testing" +) + +func TestMessageValidateAndSanitizeAttachment(t *testing.T) { + msg := &Message{ + To: []string{"user@example.com"}, + Subject: "Report", + Body: "hello", + Attachments: []Attachment{ + {Filename: "../report\r\n.txt", Content: []byte("x")}, + }, + } + if err := msg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if strings.ContainsAny(msg.Attachments[0].Filename, "\r\n/\\") { + t.Fatalf("attachment filename not sanitized: %q", msg.Attachments[0].Filename) + } +} + +func TestMessageRejectsHeaderInjection(t *testing.T) { + msg := &Message{To: []string{"user@example.com"}, Subject: "hi\r\nBcc: bad@example.com"} + if err := msg.Validate(); err == nil { + t.Fatal("expected CRLF subject rejection") + } + msg = &Message{To: []string{"bad\r\n@example.com"}, Subject: "hi"} + if err := msg.Validate(); err == nil { + t.Fatal("expected CRLF recipient rejection") + } +} diff --git a/registry/mail/src/go/template_test.go b/registry/mail/src/go/template_test.go new file mode 100644 index 0000000..260b55b --- /dev/null +++ b/registry/mail/src/go/template_test.go @@ -0,0 +1,29 @@ +package mail + +import ( + "strings" + "testing" +) + +func TestTemplateRenderMessageEscapesHTML(t *testing.T) { + tm := NewTemplateManager() + if err := tm.AddTextTemplate("text", "Hello {{.Name}}"); err != nil { + t.Fatalf("AddTextTemplate: %v", err) + } + if err := tm.AddHTMLTemplate("welcome_html", "

{{.Name}}

"); err != nil { + t.Fatalf("AddHTMLTemplate: %v", err) + } + msg, err := tm.RenderMessage("text", "welcome_html", map[string]string{"Name": ""}, []string{"ada@example.com"}, "Hi") + if err != nil { + t.Fatalf("RenderMessage: %v", err) + } + if msg.Body != "Hello " { + t.Fatalf("text body = %q", msg.Body) + } + if strings.Contains(msg.HTML, "") || !strings.Contains(msg.HTML, "<Ada>") { + t.Fatalf("HTML was not escaped: %q", msg.HTML) + } + if _, err := tm.RenderMessage("", "", nil, nil, "Hi"); err == nil { + t.Fatal("expected missing template error") + } +} diff --git a/registry/middleware/README.md b/registry/middleware/README.md new file mode 100644 index 0000000..3d6d5a0 --- /dev/null +++ b/registry/middleware/README.md @@ -0,0 +1,63 @@ +# Middleware Module + +Framework-agnostic HTTP middleware for standard `net/http` handlers. + +## What's Included + +- Middleware chaining +- Panic recovery +- CORS +- Request logging with `log/slog` +- Request ID +- Timeout +- Body size limit +- Trusted proxy helper +- Debug and trace helpers + +## Quick Copy + +```bash +cp -r registry/middleware/src/go/*.go yourproject/internal/middleware/ +``` + +Or with the Scion CLI: + +```bash +scion add middleware --to internal/middleware +``` + +## Usage + +```go +handler := middleware.Chain( + middleware.Recovery(), + middleware.RequestID(), + middleware.Logging(), + middleware.BodyLimit(10<<20), +)(mux) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `chain.go` | Middleware composition | +| `config.go` | Shared security defaults | +| `recovery.go` | Panic recovery | +| `cors.go` | CORS handling | +| `logging.go` | Structured request logging | +| `requestid.go` | Request ID generation and validation | +| `timeout.go` | Request timeout middleware | +| `bodylimit.go` | Request body limits | +| `proxy.go` | Trusted proxy support | +| `trace.go` | Trace ID helpers | +| `debug.go` | Debug route guard | +| `context.go` | Context helper keys | +| `rw.go` | Response writer wrapper | + +## Tests + +```bash +cd registry/middleware/src/go +go test -v ./... +``` diff --git a/registry/middleware/__llms__.md b/registry/middleware/__llms__.md new file mode 100644 index 0000000..f33a905 --- /dev/null +++ b/registry/middleware/__llms__.md @@ -0,0 +1,3 @@ +# Middleware + +Zero-dependency Go `net/http` middleware module. Copy `src/go/*.go` into `internal/middleware`. Exposes standard `func(http.Handler) http.Handler` middleware for chaining, recovery, CORS, structured logging, request IDs, timeouts, body limits, trusted proxies, tracing, and debug guards. Security behavior rejects CRLF/null bytes in headers and request IDs and does not trust client-controlled proxy headers by default. diff --git a/registry/middleware/src/go/context_test.go b/registry/middleware/src/go/context_test.go new file mode 100644 index 0000000..65087ea --- /dev/null +++ b/registry/middleware/src/go/context_test.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "context" + "testing" +) + +func TestContextKeysDoNotCollide(t *testing.T) { + ctx := context.Background() + ctx = context.WithValue(ctx, requestIDKey, "request") + ctx = context.WithValue(ctx, clientIPKey, "client") + ctx = context.WithValue(ctx, traceIDKey, "trace") + if ctx.Value(requestIDKey) == ctx.Value(clientIPKey) { + t.Fatal("request and client IP keys collided") + } + if ctx.Value(traceIDKey) != "trace" { + t.Fatalf("trace key lookup failed: %v", ctx.Value(traceIDKey)) + } +} diff --git a/registry/middleware/src/go/rw_test.go b/registry/middleware/src/go/rw_test.go new file mode 100644 index 0000000..c306866 --- /dev/null +++ b/registry/middleware/src/go/rw_test.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestWrappedWriterTracksStatusOnce(t *testing.T) { + rec := httptest.NewRecorder() + w := &wrappedWriter{ResponseWriter: rec} + w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusTeapot) + if w.status != http.StatusCreated || !w.wrote { + t.Fatalf("status tracking failed: %+v", w) + } + if rec.Code != http.StatusCreated { + t.Fatalf("recorder code = %d", rec.Code) + } + if w.Unwrap() != rec { + t.Fatal("Unwrap should expose underlying writer") + } +} + +func TestWrappedWriterWriteImplicitOK(t *testing.T) { + rec := httptest.NewRecorder() + w := &wrappedWriter{ResponseWriter: rec} + if _, err := w.Write([]byte("ok")); err != nil { + t.Fatalf("Write: %v", err) + } + if w.status != http.StatusOK || rec.Code != http.StatusOK { + t.Fatalf("implicit status failed: wrapper=%d recorder=%d", w.status, rec.Code) + } +} diff --git a/registry/pagination/README.md b/registry/pagination/README.md new file mode 100644 index 0000000..8f26825 --- /dev/null +++ b/registry/pagination/README.md @@ -0,0 +1,51 @@ +# Pagination Module + +Offset and cursor pagination helpers for HTTP APIs. + +## What's Included + +- Offset/limit parsing +- Cursor parsing and validation +- Response envelope helpers +- HTTP middleware for request-scoped params +- Default and maximum limit enforcement + +## Quick Copy + +```bash +cp -r registry/pagination/src/go/*.go yourproject/internal/pagination/ +``` + +Or with the Scion CLI: + +```bash +scion add pagination --to internal/pagination +``` + +## Usage + +```go +opts := pagination.Defaults() +pager := pagination.NewOffsetPaginator[User](opts) +params := pager.Parse(r) +resp := pager.Paginate(items, total, params) +_ = json.NewEncoder(w).Encode(resp) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `config.go` | Options, defaults, environment loading | +| `offset.go` | Offset pagination parsing | +| `cursor.go` | Cursor encoding and parsing | +| `middleware.go` | Request context middleware | +| `response.go` | Response envelopes | +| `pentest_test.go` | Input abuse tests | + +## Tests + +```bash +cd registry/pagination/src/go +go test -v ./... +``` diff --git a/registry/pagination/__llms__.md b/registry/pagination/__llms__.md new file mode 100644 index 0000000..8fe1ca9 --- /dev/null +++ b/registry/pagination/__llms__.md @@ -0,0 +1,3 @@ +# Pagination + +Zero-dependency Go pagination module. Copy `src/go/*.go` into `internal/pagination`. Provides offset and cursor parameter parsing, default/max limit enforcement, request-context middleware, and response builders. Inputs reject CRLF/null bytes and oversize cursor/sort values. Cursor helpers use base64 URL encoding with JSON payloads; users own database query integration. diff --git a/registry/pagination/src/go/config_test.go b/registry/pagination/src/go/config_test.go new file mode 100644 index 0000000..67d257c --- /dev/null +++ b/registry/pagination/src/go/config_test.go @@ -0,0 +1,14 @@ +package pagination + +import "testing" + +func TestConfigDefaultsAndNormalize(t *testing.T) { + defaults := Defaults() + if defaults.DefaultLimit != 20 || defaults.MaxLimit != 100 || defaults.MaxCursorLen != 256 { + t.Fatalf("unexpected defaults: %+v", defaults) + } + opts := (Options{DefaultLimit: 500, MaxLimit: 10, MaxOffset: -1}).normalize() + if opts.DefaultLimit != 10 || opts.MaxLimit != 10 || opts.MaxOffset != 0 || opts.MaxCursorLen != 256 { + t.Fatalf("normalize failed: %+v", opts) + } +} diff --git a/registry/pagination/src/go/middleware_test.go b/registry/pagination/src/go/middleware_test.go new file mode 100644 index 0000000..2f2682b --- /dev/null +++ b/registry/pagination/src/go/middleware_test.go @@ -0,0 +1,32 @@ +package pagination + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewareStoresPaginationContext(t *testing.T) { + mw := Middleware(Options{DefaultLimit: 5, MaxLimit: 10}) + req := httptest.NewRequest(http.MethodGet, "/items?limit=9&cursor=bad!!!", nil) + rec := httptest.NewRecorder() + called := false + mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + if opts, ok := OptionsFromContext(r.Context()); !ok || opts.MaxLimit != 10 { + t.Fatalf("missing options: %+v %v", opts, ok) + } + if offset, ok := OffsetFromContext(r.Context()); !ok || offset.Limit != 9 { + t.Fatalf("missing offset: %+v %v", offset, ok) + } + if cursor, ok := CursorFromContext(r.Context()); !ok || cursor.Limit != 9 { + t.Fatalf("missing cursor: %+v %v", cursor, ok) + } + if CursorErrorFromContext(r.Context()) == nil { + t.Fatal("expected cursor parse error") + } + })).ServeHTTP(rec, req) + if !called { + t.Fatal("next handler was not called") + } +} diff --git a/registry/pagination/src/go/response_test.go b/registry/pagination/src/go/response_test.go new file mode 100644 index 0000000..8a7f549 --- /dev/null +++ b/registry/pagination/src/go/response_test.go @@ -0,0 +1,29 @@ +package pagination + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func TestResponseMarshalNilDataAsArray(t *testing.T) { + data, err := json.Marshal(PaginatedResult[string]{}) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + if !strings.Contains(string(data), `"data":[]`) { + t.Fatalf("nil data should marshal as []: %s", data) + } +} + +func TestResponseWriteJSON(t *testing.T) { + rec := httptest.NewRecorder() + PaginatedResult[int]{Data: []int{1}}.WriteJSON(rec) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q", ct) + } + if !strings.Contains(rec.Body.String(), `"data":[1]`) { + t.Fatalf("body = %q", rec.Body.String()) + } +} diff --git a/registry/ratelimit/README.md b/registry/ratelimit/README.md new file mode 100644 index 0000000..dada892 --- /dev/null +++ b/registry/ratelimit/README.md @@ -0,0 +1,50 @@ +# Rate Limit Module + +Fixed window, sliding window, and token bucket rate limiters with HTTP middleware. + +## What's Included + +- Fixed window limiter +- Sliding window limiter +- Token bucket limiter +- In-memory store with max bucket bounds and LRU-style eviction +- `net/http` middleware +- Key length and injection validation + +## Quick Copy + +```bash +cp -r registry/ratelimit/src/go/*.go yourproject/internal/ratelimit/ +``` + +Or with the Scion CLI: + +```bash +scion add ratelimit --to internal/ratelimit +``` + +## Usage + +```go +store := ratelimit.NewMemoryStore() +limiter, _ := ratelimit.NewSlidingWindowLimiter(store, 100, time.Minute) +handler := ratelimit.Middleware(limiter, ratelimit.KeyByIP)(next) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `fixed_window.go` | Fixed window algorithm | +| `sliding_window.go` | Sliding window algorithm | +| `token_bucket.go` | Token bucket algorithm | +| `store.go` | Bounded in-memory state store | +| `middleware.go` | HTTP middleware | +| `pentest_test.go` | Abuse and memory-bound tests | + +## Tests + +```bash +cd registry/ratelimit/src/go +go test -v ./... +``` diff --git a/registry/ratelimit/__llms__.md b/registry/ratelimit/__llms__.md new file mode 100644 index 0000000..94ce4cb --- /dev/null +++ b/registry/ratelimit/__llms__.md @@ -0,0 +1,3 @@ +# Ratelimit + +Zero-dependency Go rate limiting module. Copy `src/go/*.go` into `internal/ratelimit`. Provides fixed window, sliding window, token bucket, bounded in-memory store, and standard `net/http` middleware. `KeyByIP` uses `r.RemoteAddr` only and ignores `X-Forwarded-For`/`X-Real-IP`. Middleware replaces empty, CRLF, and null-byte keys with `anonymous`, truncates oversized keys, and the store evicts when max buckets is reached. diff --git a/registry/ratelimit/src/go/fixed_window_test.go b/registry/ratelimit/src/go/fixed_window_test.go new file mode 100644 index 0000000..8993715 --- /dev/null +++ b/registry/ratelimit/src/go/fixed_window_test.go @@ -0,0 +1,32 @@ +package ratelimit + +import ( + "errors" + "testing" + "time" +) + +func TestFixedWindowLimiterAllowsThenDenies(t *testing.T) { + limiter, err := NewFixedWindowLimiter(NewMemoryStore(), 1, time.Minute) + if err != nil { + t.Fatalf("NewFixedWindowLimiter: %v", err) + } + if result := limiter.Allow("client"); !result.Allowed || result.Remaining != 0 { + t.Fatalf("first request = %+v", result) + } + if result := limiter.Allow("client"); result.Allowed || result.RetryAfter < 1 { + t.Fatalf("second request should be denied: %+v", result) + } +} + +func TestFixedWindowLimiterRejectsInvalidConfig(t *testing.T) { + if _, err := NewFixedWindowLimiter(nil, 1, time.Second); !errors.Is(err, ErrNilStore) { + t.Fatalf("nil store = %v", err) + } + if _, err := NewFixedWindowLimiter(NewMemoryStore(), 0, time.Second); !errors.Is(err, ErrInvalidRate) { + t.Fatalf("bad rate = %v", err) + } + if _, err := NewFixedWindowLimiter(NewMemoryStore(), 1, 0); !errors.Is(err, ErrInvalidWindow) { + t.Fatalf("bad window = %v", err) + } +} diff --git a/registry/ratelimit/src/go/middleware.go b/registry/ratelimit/src/go/middleware.go index 4e33199..a510fe0 100644 --- a/registry/ratelimit/src/go/middleware.go +++ b/registry/ratelimit/src/go/middleware.go @@ -45,7 +45,7 @@ type rateLimitResponse struct { // // On every request: // 1. The key is extracted using keyFunc (defaults to KeyByIP if nil). -// 2. Empty keys are replaced with "anonymous". +// 2. Empty keys and keys containing CRLF/null bytes are replaced with "anonymous". // 3. Keys exceeding MaxKeyLength are truncated. // 4. The limiter checks if the request is allowed. // 5. Rate limit headers are set on all responses. @@ -61,13 +61,7 @@ func Middleware(limiter Limiter, keyFunc KeyFunc) func(http.Handler) http.Handle return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Extract and sanitize the key - key := keyFunc(r) - if key == "" { - key = "anonymous" - } - if len(key) > MaxKeyLength { - key = key[:MaxKeyLength] - } + key := normalizeKey(keyFunc(r)) // Check the rate limit result := limiter.Allow(key) @@ -97,24 +91,23 @@ func Middleware(limiter Limiter, keyFunc KeyFunc) func(http.Handler) http.Handle } // Key Functions -// KeyByIP extracts the client IP address from the request. -// It checks the following in order: -// 1. X-Forwarded-For header (first IP in the list) -// 2. X-Real-IP header -// 3. RemoteAddr (host:port -> host) -func KeyByIP(r *http.Request) string { - // Check X-Forwarded-For (may contain a comma-separated list of IPs) - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if idx := strings.IndexByte(xff, ','); idx != -1 { - return strings.TrimSpace(xff[:idx]) - } - return strings.TrimSpace(xff) +// normalizeKey bounds and validates a limiter key before it reaches the store. +func normalizeKey(key string) string { + if key == "" || strings.ContainsAny(key, "\r\n\x00") { + return "anonymous" } - // Check X-Real-IP - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return strings.TrimSpace(xri) + if len(key) > MaxKeyLength { + return key[:MaxKeyLength] } - // Fall back to RemoteAddr + return key +} + +// KeyByIP extracts the client IP address from r.RemoteAddr only. +// +// It deliberately does not trust X-Forwarded-For or X-Real-IP. Those headers +// are client-controlled unless a deployment has a verified trusted-proxy layer, +// and using them here would let attackers spoof rate-limit buckets. +func KeyByIP(r *http.Request) string { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr diff --git a/registry/ratelimit/src/go/middleware_test.go b/registry/ratelimit/src/go/middleware_test.go index 856c455..9e4c704 100644 --- a/registry/ratelimit/src/go/middleware_test.go +++ b/registry/ratelimit/src/go/middleware_test.go @@ -552,29 +552,34 @@ func TestKeyByIP(t *testing.T) { want: "192.168.1.1", }, { - name: "X-Forwarded-For single", + name: "X-Forwarded-For ignored", remoteAddr: "10.0.0.1:80", xff: "203.0.113.5", - want: "203.0.113.5", + want: "10.0.0.1", }, { - name: "X-Forwarded-For multiple", + name: "X-Forwarded-For multiple ignored", remoteAddr: "10.0.0.1:80", xff: "203.0.113.5, 70.41.3.18, 150.172.238.178", - want: "203.0.113.5", + want: "10.0.0.1", }, { - name: "X-Real-IP", + name: "X-Real-IP ignored", remoteAddr: "10.0.0.1:80", xri: "198.51.100.3", - want: "198.51.100.3", + want: "10.0.0.1", }, { - name: "X-Forwarded-For takes precedence over X-Real-IP", + name: "spoofed headers ignored", remoteAddr: "10.0.0.1:80", xff: "203.0.113.5", xri: "198.51.100.3", - want: "203.0.113.5", + want: "10.0.0.1", + }, + { + name: "RemoteAddr without port", + remoteAddr: "10.0.0.1", + want: "10.0.0.1", }, } @@ -595,6 +600,23 @@ func TestKeyByIP(t *testing.T) { } } +func TestMiddlewareNormalizesUnsafeKeys(t *testing.T) { + store := NewMemoryStore() + limiter, _ := NewFixedWindowLimiter(store, 1, time.Second) + mw := Middleware(limiter, func(r *http.Request) string { + return "bad\r\nkey" + }) + handler := mw(okHandler()) + + handler.ServeHTTP(httptest.NewRecorder(), newRequestWithIP("1.1.1.1:80")) + if _, ok := store.Get("bad\r\nkey"); ok { + t.Fatal("unsafe key should not be stored") + } + if _, ok := store.Get("anonymous"); !ok { + t.Fatal("unsafe key should fall back to anonymous") + } +} + func TestKeyByUserID(t *testing.T) { fn := KeyByUserID("X-User-ID") diff --git a/registry/ratelimit/src/go/sliding_window_test.go b/registry/ratelimit/src/go/sliding_window_test.go new file mode 100644 index 0000000..6e1211e --- /dev/null +++ b/registry/ratelimit/src/go/sliding_window_test.go @@ -0,0 +1,32 @@ +package ratelimit + +import ( + "errors" + "testing" + "time" +) + +func TestSlidingWindowLimiterAllowsThenDenies(t *testing.T) { + limiter, err := NewSlidingWindowLimiter(NewMemoryStore(), 1, time.Minute) + if err != nil { + t.Fatalf("NewSlidingWindowLimiter: %v", err) + } + if result := limiter.Allow("client"); !result.Allowed { + t.Fatalf("first request = %+v", result) + } + if result := limiter.Allow("client"); result.Allowed || result.Limit != 1 { + t.Fatalf("second request should be denied: %+v", result) + } +} + +func TestSlidingWindowLimiterRejectsInvalidConfig(t *testing.T) { + if _, err := NewSlidingWindowLimiter(nil, 1, time.Second); !errors.Is(err, ErrNilStore) { + t.Fatalf("nil store = %v", err) + } + if _, err := NewSlidingWindowLimiter(NewMemoryStore(), 0, time.Second); !errors.Is(err, ErrInvalidRate) { + t.Fatalf("bad rate = %v", err) + } + if _, err := NewSlidingWindowLimiter(NewMemoryStore(), 1, 0); !errors.Is(err, ErrInvalidWindow) { + t.Fatalf("bad window = %v", err) + } +} diff --git a/registry/ratelimit/src/go/store_test.go b/registry/ratelimit/src/go/store_test.go new file mode 100644 index 0000000..f53d01d --- /dev/null +++ b/registry/ratelimit/src/go/store_test.go @@ -0,0 +1,35 @@ +package ratelimit + +import ( + "testing" + "time" +) + +func TestStoreMemoryEvictsLeastRecentlyUsed(t *testing.T) { + store := NewMemoryStoreWithLimit(2) + store.Set("a", 1) + store.Set("b", 2) + if _, ok := store.Get("a"); !ok { + t.Fatal("expected a") + } + store.Set("c", 3) + if _, ok := store.Get("b"); ok { + t.Fatal("b should have been evicted") + } + if store.Len() != 2 || store.MaxBuckets() != 2 { + t.Fatalf("unexpected store bounds len=%d max=%d", store.Len(), store.MaxBuckets()) + } + store.Delete("a") + if _, ok := store.Get("a"); ok { + t.Fatal("a should be deleted") + } +} + +func TestStoreCeilDivSeconds(t *testing.T) { + if got := ceilDivSeconds(1500 * int64(time.Millisecond)); got != 2 { + t.Fatalf("ceilDivSeconds = %d", got) + } + if got := ceilDivSeconds(0); got != 1 { + t.Fatalf("ceilDivSeconds minimum = %d", got) + } +} diff --git a/registry/ratelimit/src/go/token_bucket_test.go b/registry/ratelimit/src/go/token_bucket_test.go new file mode 100644 index 0000000..f940eb2 --- /dev/null +++ b/registry/ratelimit/src/go/token_bucket_test.go @@ -0,0 +1,31 @@ +package ratelimit + +import ( + "errors" + "testing" +) + +func TestTokenBucketLimiterAllowsBurstThenDenies(t *testing.T) { + limiter, err := NewTokenBucketLimiter(NewMemoryStore(), 1, 1) + if err != nil { + t.Fatalf("NewTokenBucketLimiter: %v", err) + } + if result := limiter.Allow("client"); !result.Allowed { + t.Fatalf("first request = %+v", result) + } + if result := limiter.Allow("client"); result.Allowed || result.RetryAfter < 1 { + t.Fatalf("second request should be denied: %+v", result) + } +} + +func TestTokenBucketLimiterRejectsInvalidConfig(t *testing.T) { + if _, err := NewTokenBucketLimiter(nil, 1, 1); !errors.Is(err, ErrNilStore) { + t.Fatalf("nil store = %v", err) + } + if _, err := NewTokenBucketLimiter(NewMemoryStore(), 0, 1); !errors.Is(err, ErrInvalidRate) { + t.Fatalf("bad rate = %v", err) + } + if _, err := NewTokenBucketLimiter(NewMemoryStore(), 1, 0); !errors.Is(err, ErrInvalidCapacity) { + t.Fatalf("bad capacity = %v", err) + } +} diff --git a/registry/rbac/README.md b/registry/rbac/README.md new file mode 100644 index 0000000..92bdf39 --- /dev/null +++ b/registry/rbac/README.md @@ -0,0 +1,58 @@ +# RBAC Module + +Role-based access control with wildcard permissions and hierarchy inheritance. + +## What's Included + +- Role and permission models +- Permission wildcard matching +- Role inheritance +- Cycle detection +- Context helpers +- `net/http` authorization middleware + +## Quick Copy + +```bash +cp -r registry/rbac/src/go/*.go yourproject/internal/rbac/ +``` + +Or with the Scion CLI: + +```bash +scion add rbac --to internal/rbac +``` + +## Usage + +```go +manager := rbac.NewManager() +_ = manager.AddRole(&rbac.Role{ + Name: "admin", + Permissions: []rbac.Permission{ + rbac.ParsePermission("*:*"), + }, +}) +_ = manager.AssignRole("user-1", "admin") + +handler := manager.RequirePermission("posts:write")(next) +ctx := rbac.WithUser(r.Context(), "user-1") +handler.ServeHTTP(w, r.WithContext(ctx)) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `model.go` | Role and permission model | +| `manager.go` | Role registration, matching, and hierarchy | +| `context.go` | Context role helpers | +| `middleware.go` | HTTP authorization middleware | +| `pentest_test.go` | Permission bypass tests | + +## Tests + +```bash +cd registry/rbac/src/go +go test -v ./... +``` diff --git a/registry/rbac/__llms__.md b/registry/rbac/__llms__.md new file mode 100644 index 0000000..9a08975 --- /dev/null +++ b/registry/rbac/__llms__.md @@ -0,0 +1,3 @@ +# RBAC + +Zero-dependency Go RBAC module. Copy `src/go/*.go` into `internal/rbac`. Uses explicit roles and permissions with wildcard matching, hierarchy inheritance, and cycle detection. Role IDs and permissions reject empty, oversized, CRLF, and null-byte values. HTTP middleware reads roles from request context; integrate it after authentication middleware that sets trusted roles. diff --git a/registry/rbac/src/go/context_test.go b/registry/rbac/src/go/context_test.go new file mode 100644 index 0000000..8911f49 --- /dev/null +++ b/registry/rbac/src/go/context_test.go @@ -0,0 +1,23 @@ +package rbac + +import ( + "context" + "testing" +) + +func TestContextHelpersRoundTrip(t *testing.T) { + ctx := context.Background() + ctx = WithUser(ctx, "user-1") + ctx = WithRoles(ctx, []string{"admin"}) + ctx = WithPermissions(ctx, []Permission{ParsePermission("posts:write")}) + + if user, ok := GetUserFromContext(ctx); !ok || user != "user-1" { + t.Fatalf("user = %q %v", user, ok) + } + if roles, ok := GetRolesFromContext(ctx); !ok || len(roles) != 1 || roles[0] != "admin" { + t.Fatalf("roles = %+v %v", roles, ok) + } + if perms, ok := GetPermissionsFromContext(ctx); !ok || len(perms) != 1 || perms[0].String() != "posts:write" { + t.Fatalf("perms = %+v %v", perms, ok) + } +} diff --git a/registry/rbac/src/go/middleware_test.go b/registry/rbac/src/go/middleware_test.go new file mode 100644 index 0000000..256e370 --- /dev/null +++ b/registry/rbac/src/go/middleware_test.go @@ -0,0 +1,36 @@ +package rbac + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewareRequirePermissionHTTPFlow(t *testing.T) { + manager := NewManager() + if err := manager.AddRole(&Role{Name: "editor", Permissions: []Permission{ParsePermission("posts:write")}}); err != nil { + t.Fatalf("AddRole: %v", err) + } + if err := manager.AssignRole("user-1", "editor"); err != nil { + t.Fatalf("AssignRole: %v", err) + } + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + handler := manager.RequirePermission("posts:write")(next) + + unauth := httptest.NewRecorder() + handler.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/", nil)) + if unauth.Code != http.StatusUnauthorized { + t.Fatalf("unauth code = %d", unauth.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(WithUser(req.Context(), "user-1")) + ok := httptest.NewRecorder() + handler.ServeHTTP(ok, req) + if ok.Code != http.StatusNoContent { + t.Fatalf("allowed code = %d", ok.Code) + } +} diff --git a/registry/rbac/src/go/model_test.go b/registry/rbac/src/go/model_test.go new file mode 100644 index 0000000..64c3a0a --- /dev/null +++ b/registry/rbac/src/go/model_test.go @@ -0,0 +1,19 @@ +package rbac + +import "testing" + +func TestModelPermissionParsingAndMatching(t *testing.T) { + perm := ParsePermission("posts:write") + if perm.Resource != "posts" || perm.Action != "write" || perm.String() != "posts:write" { + t.Fatalf("unexpected permission: %+v", perm) + } + if !matches(ParsePermission("posts:*"), perm) { + t.Fatal("resource wildcard should match") + } + if !matches(ParsePermission("*:write"), perm) { + t.Fatal("action wildcard should match") + } + if validateName("") || validateName("bad\r\n") { + t.Fatal("unsafe names should be rejected") + } +} diff --git a/registry/validation/README.md b/registry/validation/README.md new file mode 100644 index 0000000..212c0ab --- /dev/null +++ b/registry/validation/README.md @@ -0,0 +1,55 @@ +# Validation Module + +Chainable validation builder for request DTOs and HTTP handlers. + +## What's Included + +- Generic validation builder +- Field-level rules +- Structured validation errors +- HTTP middleware +- CRLF and null-byte rejection rules +- Length and regex protections + +## Quick Copy + +```bash +cp -r registry/validation/src/go/*.go yourproject/internal/validation/ +``` + +Or with the Scion CLI: + +```bash +scion add validation --to internal/validation +``` + +## Usage + +```go +rules := validation.New(). + Field("email").Required().Email().Length(1, 255). + Field("name").Required().Length(2, 100) + +if errs := rules.ValidateJSON(r); errs.HasErrors() { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"errors": errs}) + return +} +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `validator.go` | Builder and validation execution | +| `rules.go` | Built-in validation rules | +| `errors.go` | Error types and formatting | +| `middleware.go` | HTTP JSON validation middleware | +| `pentest_test.go` | Malicious input tests | + +## Tests + +```bash +cd registry/validation/src/go +go test -v ./... +``` diff --git a/registry/validation/__llms__.md b/registry/validation/__llms__.md new file mode 100644 index 0000000..041068b --- /dev/null +++ b/registry/validation/__llms__.md @@ -0,0 +1,3 @@ +# Validation + +Zero-dependency Go validation module. Copy `src/go/*.go` into `internal/validation`. Provides generic builder-style validation for DTO structs, field rules, structured errors, and HTTP middleware. Use max-length rules on all user strings and add CRLF/null-byte rejection where strings may flow into headers, logs, paths, URLs, or SQL filters. Regex rules rely on Go RE2 semantics. diff --git a/registry/validation/src/go/errors_test.go b/registry/validation/src/go/errors_test.go new file mode 100644 index 0000000..5447f4a --- /dev/null +++ b/registry/validation/src/go/errors_test.go @@ -0,0 +1,34 @@ +package validation + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func TestErrorsJSONAndWriteJSON(t *testing.T) { + ve := NewValidationError() + if ve.HasErrors() { + t.Fatal("new error should be empty") + } + ve.Add("email", "is invalid") + if !ve.HasErrors() || !strings.Contains(ve.Error(), "email") { + t.Fatalf("unexpected error state: %v", ve) + } + data, err := json.Marshal(ve) + if err != nil { + t.Fatalf("MarshalJSON: %v", err) + } + if !strings.Contains(string(data), `"email"`) { + t.Fatalf("json = %s", data) + } + + rec := httptest.NewRecorder() + if err := ve.WriteJSON(rec, 422); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + if rec.Code != 422 || !strings.Contains(rec.Body.String(), "email") { + t.Fatalf("response code=%d body=%q", rec.Code, rec.Body.String()) + } +} diff --git a/registry/validation/src/go/middleware_test.go b/registry/validation/src/go/middleware_test.go new file mode 100644 index 0000000..4eaa3cf --- /dev/null +++ b/registry/validation/src/go/middleware_test.go @@ -0,0 +1,41 @@ +package validation + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMiddlewareRejectsInvalidRequest(t *testing.T) { + schema := New(WithSource(QuerySource)).Field("email").Required().Email() + handler := Middleware(schema)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?email=bad", nil)) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "email") { + t.Fatalf("invalid response code=%d body=%q", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?email=a@example.com", nil)) + if rec.Code != http.StatusNoContent { + t.Fatalf("valid response code=%d body=%q", rec.Code, rec.Body.String()) + } +} + +func TestMiddlewareRecoversValidationPanic(t *testing.T) { + schema := New(WithSource(QuerySource)). + Field("value"). + Custom("panic", func(string) error { panic("boom") }) + handler := Middleware(schema)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?value=x", nil)) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "_server") { + t.Fatalf("panic response code=%d body=%q", rec.Code, rec.Body.String()) + } +} diff --git a/registry/validation/src/go/rules_test.go b/registry/validation/src/go/rules_test.go new file mode 100644 index 0000000..26ddc1c --- /dev/null +++ b/registry/validation/src/go/rules_test.go @@ -0,0 +1,29 @@ +package validation + +import "testing" + +func TestRulesBuiltIns(t *testing.T) { + rules := []struct { + name string + rule Rule + ok string + bad string + }{ + {"email", emailRule{}, "a@example.com", "bad"}, + {"url", urlRule{}, "https://example.com", "javascript:alert(1)"}, + {"uuid", uuidRule{}, "550e8400-e29b-41d4-a716-446655440000", "bad"}, + {"ip", ipRule{}, "127.0.0.1", "bad"}, + {"in", newInRule([]string{"red"}), "red", "blue"}, + {"regex", newRegexRule(`^[a-z]+$`, MaxRegexLength), "abc", "123"}, + } + for _, tc := range rules { + t.Run(tc.name, func(t *testing.T) { + if err := tc.rule.Validate(tc.ok, true); err != nil { + t.Fatalf("ok value rejected: %v", err) + } + if err := tc.rule.Validate(tc.bad, true); err == nil { + t.Fatal("bad value accepted") + } + }) + } +}