diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc729c..657e926 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,27 @@ jobs: - name: Test composed WASI credentials run: make wasi-credential-test + repo-viewer: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: examples/git/viewer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.13" + - name: Install wac + run: | + mkdir -p "$RUNNER_TEMP/wasi-tools" + curl -fsSL https://github.com/bytecodealliance/wac/releases/download/v0.11.0/wac-cli-x86_64-unknown-linux-musl -o "$RUNNER_TEMP/wasi-tools/wac" + echo "83259349b630f79a60490322b068e7ad2dba784720b0d467decccc0aecc743b5 $RUNNER_TEMP/wasi-tools/wac" | sha256sum -c - + chmod +x "$RUNNER_TEMP/wasi-tools/wac" + echo "$RUNNER_TEMP/wasi-tools" >> "$GITHUB_PATH" + - name: Lint, typecheck, and build viewer + run: bun install --frozen-lockfile && bun run check && bun test && bun run build + spin-key-value: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/AGENTS.md b/AGENTS.md index c7fca30..eb82189 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ - Do not add garbage collection until checkpoint safety and uncertain-result resolution have tests. - Do not use OpenSpec. +- Do not add compatibility readers, migrations, or repair paths for discarded + revisions. Reject unsupported durable state and use a fresh storage namespace. ## Design diff --git a/GIT_PLAN.md b/GIT_PLAN.md index add85a1..68c5a6c 100644 --- a/GIT_PLAN.md +++ b/GIT_PLAN.md @@ -11,9 +11,9 @@ rules remain outside the Rust core. - Classic receive-pack push, branches, annotated tags, stale-write rejection, fast-forward policy, connectivity validation, and malformed-input rejection. - Atomic publication of refs and the sparse object catalog. -- Arbitrary configured repository paths with isolated WAL identities and an - explicit SHA-1 or SHA-256 format. Adding a repository requires configuration, - not source changes. +- Arbitrary repository paths with isolated WAL identities and an explicit + SHA-1 or SHA-256 format. First-push creation requires no per-repository + configuration or restart; named access policies remain optional overrides. - Per-repository reader, writer, and administrator permissions; read-only mode; a persisted default branch; and recovery without a local repository cache. - Automatic maintenance that prunes unreachable Git objects and invokes bounded @@ -84,7 +84,9 @@ The service uses unmodified upstream go-git and componentize-go, including its bundled adapter. It pins the unchanged go-pkg PR #13 revision pending upstream review. Exact revisions, licenses, and upstream references live in `THIRD_PARTY.md`. New fork-only behavior requires owner review and focused tests. The service uses -ordinary Spin and unmodified object storage. +ordinary Spin and unmodified object storage. The optional TypeScript viewer +uses the owner-approved opaque-resource compiler fix in ComponentizeJS PR #357; +remove its package override when a release includes the fix. ## Service readiness @@ -100,9 +102,12 @@ in #39. The declarative repository map supplies canonical nested paths, stable WAL identities, immutable object formats, default branches and independent read/write/admin groups. Duplicate identities, aliases and malformed paths are -rejected. Unknown repositories fail closed. Reads and administration open -existing WALs; an authorized first-push discovery can materialize a configured -repository before accepting a pack. +rejected. A wildcard policy admits authorized first pushes to new names. +Missing repositories remain absent on reads; discovery for a first push advertises +both formats without initializing storage. The first successful push publishes +format, default branch, refs and catalog through the existing repository WAL. +Scheduled maintenance currently requires named entries; dynamic discovery is +separate work. Remote tests added a nested SHA-256 repository through configuration while preserving the component artifact. Read discovery returned 404 with no durable diff --git a/HANDOFF.md b/HANDOFF.md index 87079e2..56e8673 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -58,9 +58,12 @@ The Git service supports unchanged SHA-1 and SHA-256 clients, protocol-v2 clone and have-aware fetch, shallow history, classic push, branches, tags, access control, cold recovery, automatic tail checkpoints, and explicit maintenance. Refs and its sparse object catalog publish atomically through one WAL commit. -Local MinIO tests pass with unmodified go-git. Repository paths, formats, default -branches and per-action groups now come from configuration. Only an authorized -writer can materialize a configured repository; reads open existing state. +Local MinIO tests pass with unmodified go-git. The first successful authorized +push creates a repository without per-name configuration. A wildcard policy +supplies defaults; exact entries replace it. Canonical names derive isolated WAL +identities. The initial push publishes format, default branch, refs and catalog +through one WAL commit; browsing and rejected pushes publish no Git root. +The host maintenance worker still visits only named configuration entries. Cognito access-token validation and a separate administration-only machine client are wired. Password mode is the explicit local default and requires a password. Public EC2 HTTPS qualification has passed real Cognito browser login, @@ -87,6 +90,11 @@ go-pkg PR #13 while it remains under review. Dependency provenance and reference authority. Use ordinary Spin and unmodified S3-compatible storage. +The optional repository viewer has its own TypeScript Spin API and reads the +existing WAL catalog; it adds no routes or code to the Go Git service. Its +compiler temporarily uses the focused opaque-resource fix in ComponentizeJS +PR #357, with exact provenance in `THIRD_PARTY.md`. + Spin 4.1's affected OpenTelemetry SDK remains pinned pending its coordinated upstream upgrade. The optional Caddy host rejects oversized or overlong W3C `baggage` before Spin; other HTTP embeddings must enforce the same boundary. diff --git a/Makefile b/Makefile index 5e8a4a2..f3f9ff8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check test bench minio-test minio-performance gc-acceptance git-check git-build git-spin-config-test wasi-credential-test git-provider-test git-qualification-tools-test +.PHONY: check test bench minio-test minio-performance gc-acceptance git-check git-build git-local git-spin-config-test wasi-credential-test git-provider-test git-qualification-tools-test check: cargo fmt --all --check @@ -25,7 +25,7 @@ gc-acceptance: GIT_EXAMPLE_DIR = examples/git WAL_COMPONENT = examples/wal-component/Cargo.toml -GIT_TEST_FILES = access.go access_test.go auth.go auth_test.go repositories.go repositories_test.go imports.go imports_test.go receive_transport.go receive_transport_test.go request_config.go request_config_test.go import_pack.go import_pack_test.go import_pack_lifecycle_test.go limits.go limits_test.go index.go index_test.go codec.go codec_test.go delta.go delta_test.go read_retry.go read_retry_test.go retention.go retention_test.go retry.go retry_test.go fetch_policy.go fetch_policy_test.go validate_objects.go validate_objects_test.go validate.go validate_refname.go validate_refname_test.go validate_native_test.go +GIT_TEST_FILES = access.go access_test.go auth.go auth_test.go repositories.go repositories_test.go imports.go imports_test.go receive_transport.go receive_transport_test.go receive_progress.go receive_progress_test.go request_config.go request_config_test.go import_pack.go import_pack_test.go import_pack_lifecycle_test.go limits.go limits_test.go index.go index_test.go codec.go codec_test.go delta.go delta_test.go read_retry.go read_retry_test.go retention.go retention_test.go retry.go retry_test.go fetch_policy.go fetch_policy_test.go validate_objects.go validate_objects_test.go validate.go validate_refname.go validate_refname_test.go validate_native_test.go git-check: git-qualification-tools-test test -z "$$(gofmt -l $(GIT_EXAMPLE_DIR)/*.go $(GIT_EXAMPLE_DIR)/tests/*.go)" @@ -48,6 +48,9 @@ git-build: $(MAKE) -C $(GIT_EXAMPLE_DIR) build cd $(GIT_EXAMPLE_DIR) && wac plug --plug ../wal-component/target/wasm32-wasip2/release/object_log_component.wasm main.wasm -o git.wasm +git-local: + ./scripts/run-git-local.sh + git-spin-config-test: git-build ./scripts/test-git-spin-config.sh diff --git a/README.md b/README.md index e47bda8..6738a24 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,8 @@ host. Spin dependencies remain outside the core and KV workspaces. [AWS host](https://github.com/carsonfarmer/object-log/blob/main/examples/git/qualification/aws/HOSTING.md) supplies HTTPS, instance-role credentials, and a worker that schedules those endpoints. - Use the provider suite to qualify the intended storage, client and host limits. + Run `make git-local` for a disposable local service, or use the provider suite + to qualify the intended storage, client and host limits. The core library has no Git, Spin, or serverless-runtime dependency. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index df15fb9..f52bcd6 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -46,6 +46,25 @@ pins through go-pkg's public `Unpin` function after the bindings return Go values. Build inputs are recorded in `examples/git/Makefile` and `go.mod`; their upstream licenses apply. +## Optional TypeScript repository viewer + +The viewer uses Bun, TypeScript, Biome, Preact, `fflate`, `@noble/hashes` and the +Spin JavaScript SDK. Its `examples/git/viewer/bun.lock` records exact versions +and package integrity. Their upstream licenses apply. + +The compiler uses a temporary package based on official ComponentizeJS 0.23.0, +with the binding splicer rebuilt from +[revision `a47770b`](https://github.com/carsonfarmer/ComponentizeJS/commit/a47770bd1b63a7497d785c5b32e841812de16700). +[Upstream PR #357](https://github.com/bytecodealliance/ComponentizeJS/pull/357) +fixes missing classes for imported resources without methods, reported in +[issue #221](https://github.com/bytecodealliance/ComponentizeJS/issues/221). +The packaged JavaScript bindings and all engine/cache files remain identical +to the official release; only the compiled binding splicer changes. Source and +build details accompany the +[temporary package](https://github.com/carsonfarmer/ComponentizeJS/releases/tag/opaque-resource-v0.23.0). +Remove the package override once an upstream release includes this fix. +The compiler change adds no viewer behavior or Git-specific code. + ## Native Spin provider The isolated [Spin key-value provider](integrations/spin-key-value/README.md) diff --git a/docs/blog/02-running-git-on-the-wal.md b/docs/blog/02-running-git-on-the-wal.md index e7ea037..d49a177 100644 --- a/docs/blog/02-running-git-on-the-wal.md +++ b/docs/blog/02-running-git-on-the-wal.md @@ -39,9 +39,10 @@ of both Spin and Git, and can also run natively. Configuration maps repository paths to stable WAL identities, hash formats, default branches, and permissions. Each repository has its own storage -namespace. An authorized writer's first push discovery creates its durable -state; reads only open existing repositories. Adding a repository changes -configuration, not the component binary. +namespace. The first successful authorized push publishes the format, refs and +catalog together; reads only open existing repositories. A wildcard +access policy can admit new repository names without changing the component +binary or configuration. ## From a push to durable refs @@ -133,9 +134,8 @@ curl --fail-with-body -u git:local-git-password \ -X POST http://127.0.0.1:19100/_validate_backend ``` -The default configuration exposes -`http://127.0.0.1:19100/sha1.git` and -`http://127.0.0.1:19100/sha256.git`. +Storage starts empty. The first successful push creates a repository at the +chosen path and records its Git format with the refs. In a third terminal, push a commit and clone it back. Use an empty working directory for these commands and keep your ordinary Git author configuration: @@ -146,11 +146,11 @@ cd demo echo 'Stored through object-log.' >README.md git add README.md git commit -m 'Try object-log' -git remote add origin http://127.0.0.1:19100/sha256.git +git remote add origin http://127.0.0.1:19100/team/project.git git push -u origin main cd .. git -c protocol.version=2 clone \ - http://127.0.0.1:19100/sha256.git demo-clone + http://127.0.0.1:19100/team/project.git demo-clone git -C demo-clone fsck --full ``` diff --git a/examples/git/.gitignore b/examples/git/.gitignore index b87c806..74417da 100644 --- a/examples/git/.gitignore +++ b/examples/git/.gitignore @@ -4,3 +4,5 @@ /git.wasm /.spin/ /.componentize/ + +/spin.viewer.toml diff --git a/examples/git/README.md b/examples/git/README.md index f3aac85..2c7640a 100644 --- a/examples/git/README.md +++ b/examples/git/README.md @@ -23,99 +23,60 @@ service over HTTPS. Run the provider suite for your intended workload and host capacity before deployment. It is pre-release: use a fresh object-store prefix when changing incompatible revisions. -## Build +## Setup Install Go 1.27.1, the repository's pinned Rust toolchain, Spin CLI v4.1.0, -`wac` v0.11.0, MinIO, -and the AWS CLI. For a pinned MinIO container or a native source build, see +`wac` v0.11.0, the AWS CLI, and either MinIO or Docker. +For a pinned MinIO container or a native source build, see [the contributor setup](../../CONTRIBUTING.md). Then run from the repository root: ```sh rustup target add wasm32-wasip2 -make git-check -make git-build ``` -`git-build` creates the Go component, builds the Rust WAL component, and composes -both into `examples/git/git.wasm`. Spin loads that final file. -The first build also compiles the pinned component build tool; later builds use -the cached binary. +The first build compiles the pinned component build tool; later builds use +the cached binary. `make git-build` builds and composes the Go and Rust WAL +components into `examples/git/git.wasm` without starting the service. ## Run locally -Start an isolated MinIO instance in one terminal using the native binary from -the contributor setup. The command prints its unique data directory; remove -that directory after stopping MinIO: +With the tools above installed, run from the repository root: ```sh -minio_data="$(mktemp -d "${TMPDIR:-/tmp}/object-log-git-minio.XXXXXX")" -echo "MinIO data: $minio_data" -minio_binary="${OBJECT_LOG_MINIO_BINARY:-$(go env GOPATH)/bin/minio}" -MINIO_ROOT_USER=objectlog MINIO_ROOT_PASSWORD=local-test-secret \ - "$minio_binary" server "$minio_data" --address 127.0.0.1:19090 +make git-local ``` -In another terminal, create the bucket and a protected Spin variables file. -Each run gets a new WAL prefix: +This builds the service, starts disposable MinIO and Spin instances, creates the +bucket, and validates storage. It uses a native MinIO binary when available, +otherwise the pinned Docker image. No AWS account is involved. Storage starts +empty. Push an initial commit to any permitted repository name to create it, +entering `git` and `local-git-password` when Git prompts: ```sh -local_config="$(mktemp -d "${TMPDIR:-/tmp}/object-log-git-config.XXXXXX")" -AWS_ACCESS_KEY_ID=objectlog AWS_SECRET_ACCESS_KEY=local-test-secret \ - AWS_DEFAULT_REGION=us-east-1 aws --endpoint-url http://127.0.0.1:19090 \ - s3api create-bucket --bucket wal-proof -test_id="local-$(date -u +%Y%m%dT%H%M%SZ)-$$" -( - umask 077 - cat >"$local_config/variables.toml" <&1 | tee /tmp/object-log-git-spin.log -``` - -Before sending Git traffic, validate the same MinIO settings from another -terminal. A failed probe returns HTTP 503; fix the backend before serving: - -```sh -curl --fail-with-body -u git:local-git-password \ - -X POST http://127.0.0.1:19100/_validate_backend -``` - -The repositories are available at: - -```text -http://127.0.0.1:19100/sha1.git -http://127.0.0.1:19100/sha256.git -``` - -Initialize a repository with its first push. Enter `git` and the local password -when Git prompts. Reads never create repositories: - -```sh -git init --object-format=sha256 -b main demo +git init -b main demo cd demo echo 'Git on object-log' > README.md git add README.md git commit -m 'Initial commit' -git push http://127.0.0.1:19100/sha256.git main +git push http://127.0.0.1:19100/team/demo.git main cd .. -git clone http://127.0.0.1:19100/sha256.git cloned-demo +git clone http://127.0.0.1:19100/team/demo.git cloned-demo ``` -When finished, stop Spin and MinIO, then remove `"$local_config"` and the -printed MinIO data directory. +Git defaults to SHA-1. To use SHA-256, initialize the local repository with +`git init --object-format=sha256`; its first push selects the remote format. + +Push progress shows pack checks, imported-object counts, delta indexing, +validation, and publication. Import counts update about once a second as objects +finish; final push status follows durable publication. Use `git push --progress` +to request feedback when stderr is redirected. `-q` and `--no-progress` suppress +these messages. + +Press Ctrl-C in the service terminal to stop Spin and MinIO and remove all demo +data. Set `OBJECT_LOG_GIT_LOCAL_PORT` to choose another loopback port. +To keep data or use an existing backend, build with `make git-build`, configure +the variables below, and run Spin directly. Call `POST /_validate_backend` with +administrator credentials before serving traffic. The local `password` mode uses HTTP Basic authentication; the username is ignored. An empty password is a configuration error. Anonymous access requires @@ -126,7 +87,8 @@ rewrite branches. ## Configuration -All values are Spin variables. The defaults target the local MinIO setup above. +All values are Spin variables. The local helper fills the endpoint and +credentials for its disposable MinIO instance. | Variable | Default | Purpose | | --- | --- | --- | @@ -141,7 +103,7 @@ All values are Spin variables. The defaults target the local MinIO setup above. | `wal_collection_candidates` | `1000` | Maximum entries in a new deletion plan, capped by the durable graph limit | | `wal_max_collection_objects` | `100000` | Complete publication graph and distinct collection live-object bound; shared paths count separately for publication | | `wal_recover_retentions_after_drain` | `false` | Exclusive lost-retention recovery mode | -| `git_repositories` | two demo entries | JSON map of paths, WAL identities, formats, default branches and permissions | +| `git_repositories` | `{"*":{}}` | JSON access policies; optional fixed identities, formats and default branches | | `git_auth_mode` | `password` | `password`, `cognito`, or explicit local `anonymous` mode | | `git_password` | empty | Required password for local password mode | | `git_cognito_issuer` | empty | HTTPS Cognito user-pool issuer | @@ -186,17 +148,40 @@ configured limit high enough to read them; lowering it blocks those objects. ## Repositories and permissions -Repository names come from configuration, including nested names. For example: +The first successful push creates a repository. Names can be nested, and the +`.git` suffix is optional; both URLs select the same repository: + +```sh +git push http://127.0.0.1:19100/team/project HEAD:main +``` + +The push requires write permission and publishes its Git format, default branch, +refs and object catalog together through the existing WAL. The first pushed +branch becomes the default unless configuration specifies `default_branch`; +a tag-only first push leaves an unborn `main`. Later pushes preserve the stored +format and default branch. Read discovery never initializes storage. Failed +pushes can leave unreachable staged objects or an empty WAL head, but no +published Git repository. + +One `"*"` policy grants access to new names without a configuration edit or +restart. For Cognito, configure the groups once: ```toml -git_repositories = '{"team/project.git":{"log_id":"team-project","format":"sha1","default_branch":"main","read_groups":["developers"],"write_groups":["developers"],"admin_groups":["operators"]}}' +git_repositories = '{"*":{"read_groups":["developers"],"write_groups":["developers"],"admin_groups":["operators"]},"team/private":{"read_groups":["private-team"],"write_groups":["private-team"]}}' ``` -Each name has its own WAL identity. Duplicate identities, ambiguous names and -invalid formats are rejected. Add another entry and reload Spin to provision a -repository without rebuilding. Keep each identity stable. The first authorized -push discovery persists its format and default branch; subsequent configuration -cannot reinterpret that stored format or change its default branch. +An exact entry replaces the whole default policy; omitted groups deny access. +An omitted `log_id` derives an isolated identity from the canonical name, so +adding a permission override preserves the existing repository. Explicit IDs +must be unique and cannot use the reserved `auto-` prefix. An optional `format` +pins that policy to SHA-1 or SHA-256; otherwise readers recover the stored format. +Clients cannot change permissions or reinterpret an existing repository. +The local shared-password mode grants the password holder access to all names; +anonymous mode allows public writes, including first-push creation. + +The optional host maintenance worker currently visits named configuration entries. +Repositories created under `"*"` still checkpoint their log during pushes and expose +the same maintenance endpoints, but need an operator to schedule collection by name. Cognito mode checks signed access tokens, issuer, client, expiry and scope before opening storage. Git supplies the access token as its Basic password through an @@ -242,17 +227,21 @@ make wasi-credential-test Against a service with a fresh WAL prefix, run the unchanged-client provider suite. The suite creates its own refs; do not reuse a repository containing -manual demo commits. Stop Spin, choose a new `wal_prefix` in the variables file, +manual demo commits. `GIT_PROBE_CREATE=1` enables first-push creation checks and +gives the ordinary Git workflow its own repository names; it requires a wildcard +policy. Stop Spin, choose a new `wal_prefix` in the variables file, and restart it before the first suite run or a rerun: ```sh GIT_PROBE_URL=http://127.0.0.1:19100 \ GIT_PROBE_PASSWORD=local-git-password \ -GIT_PROBE_LOG=/tmp/object-log-git-spin.log make git-provider-test +GIT_PROBE_CREATE=1 GIT_MULTI_REPOSITORIES=1 \ +GIT_PROBE_LOG="$PWD/examples/git/.spin/logs/git_stderr.txt" make git-provider-test ``` Set `GIT_PROBE_PASSWORD` and `GIT_PROBE_BRANCH` when those values are configured. -`GIT_PROBE_LOG` supplies per-request storage counters when Spin omits HTTP trailers. +Use the Git component log path printed by `make git-local` for `GIT_PROBE_LOG`; it supplies +per-request storage counters when Spin omits HTTP trailers. The provider suite uses installed Git as an independent protocol and integrity oracle. Larger opt-in cases are controlled by these environment variables: @@ -304,20 +293,6 @@ without reading opaque leaf payloads. Ordinary reads and publication still verify those bytes. Continuous readers can delay cleanup; scheduling alone does not remove that constraint. -Repositories published before the current ref-name rule may contain refs that -Git can advertise but no longer accepts for updates or deletion. An operator -can explicitly remove only those invalid names with `POST -//prune-invalid-refs` using administrator credentials. The response -lists removed names after the new ref map is committed through the WAL head. -An empty list with no `default_head` means no change. If the stored default -HEAD is invalid, ensure another valid branch exists, then supply -its full name in `X-Git-Replacement-Head` on this call; the new HEAD and any -removed refs publish together. Without that header, the call refuses to -change an invalid default HEAD. -On a publication conflict, retry; on an uncertain result, inspect the refs -and repeat the request. This action does not run on the maintenance timer. Run -ordinary maintenance afterward to reclaim objects no longer reachable from a ref. - Every fetch acquires WAL retention before opening catalog data and releases it after the last response byte. If a stopped instance loses a retention ID, stop new traffic and drain all readers. Start one authenticated instance with diff --git a/examples/git/codec.go b/examples/git/codec.go index d5e0b32..5b3009b 100644 --- a/examples/git/codec.go +++ b/examples/git/codec.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "github.com/go-git/go-billy/v6/memfs" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/format/config" "github.com/go-git/go-git/v6/plumbing/format/objfile" + "github.com/go-git/go-git/v6/storage/filesystem/dotgit" "io" "strings" ) @@ -25,16 +27,18 @@ func decodeRoot(data []byte, format config.ObjectFormat, children int) (rootMeta if err := decodeMetadata(data, &meta); err != nil { return meta, err } - if !meta.Validated || meta.Format != format || len(meta.Buckets) != children { + validFormat := meta.Format == config.SHA1 || meta.Format == config.SHA256 + if !meta.Validated || !validFormat || len(meta.Buckets) != children { return meta, fmt.Errorf("invalid repository root") } - if meta.Head != "" { - head := plumbing.ReferenceName(meta.Head) - if !head.IsBranch() || head.Validate() != nil { - return meta, fmt.Errorf("invalid repository head") - } + if format != "" && meta.Format != format { + return meta, fmt.Errorf("%w: repository format differs from stored state", config.ErrInvalidObjectFormat) + } + refNames := dotgit.New(memfs.New()) + if head := plumbing.ReferenceName(meta.Head); !head.IsBranch() || validateRefName(refNames, head) != nil { + return meta, fmt.Errorf("invalid repository head") } - if err := validateRefs(format, meta.Refs); err != nil { + if err := validateRefs(refNames, meta.Format, meta.Refs); err != nil { return meta, err } for i, prefix := range meta.Buckets { @@ -57,10 +61,10 @@ func decodeMetadata(data []byte, value any) error { return nil } -func validateRefs(format config.ObjectFormat, refs map[string]string) error { +func validateRefs(refNames *dotgit.DotGit, format config.ObjectFormat, refs map[string]string) error { for name, id := range refs { ref := plumbing.ReferenceName(name) - if !strings.HasPrefix(name, "refs/") || ref.Validate() != nil || !validID(format, id) { + if validateRefName(refNames, ref) != nil || !validID(format, id) { return fmt.Errorf("invalid repository reference") } for parent := name; ; { diff --git a/examples/git/codec_test.go b/examples/git/codec_test.go index f811f69..c6d16a0 100644 --- a/examples/git/codec_test.go +++ b/examples/git/codec_test.go @@ -21,6 +21,31 @@ type closeProbe struct { closeErr error } +func TestRootInfersOnlyAuthenticatedSupportedFormat(t *testing.T) { + for _, format := range []config.ObjectFormat{config.SHA1, config.SHA256} { + data, err := json.Marshal(rootMeta{Validated: true, Format: format, Head: "refs/heads/main", Refs: map[string]string{ + "refs/heads/main": strings.Repeat("1", format.HexSize()), + }}) + if err != nil { + t.Fatal(err) + } + root, err := decodeRoot(data, "", 0) + if err != nil || root.Format != format { + t.Fatalf("stored format not recovered: %v", err) + } + other := config.SHA1 + if format == other { + other = config.SHA256 + } + if _, err := decodeRoot(data, other, 0); !errors.Is(err, config.ErrInvalidObjectFormat) { + t.Fatal("configured format mismatch accepted") + } + } + if _, err := decodeRoot([]byte(`{"Validated":true,"Format":"sha512","Refs":{}}`), "", 0); err == nil { + t.Fatal("unsupported stored format accepted") + } +} + func (r *closeProbe) Close() error { r.closed = true; return r.closeErr } func TestLooseRoundTripAndValidation(t *testing.T) { @@ -212,7 +237,7 @@ func TestInlineMetadataBound(t *testing.T) { func TestRepositoryRootAdmission(t *testing.T) { for _, format := range []config.ObjectFormat{config.SHA1, config.SHA256} { t.Run(format.String(), func(t *testing.T) { - empty := rootMeta{Validated: true, Format: format, Refs: map[string]string{}, Buckets: []string{}} + empty := rootMeta{Validated: true, Format: format, Head: "refs/heads/main", Refs: map[string]string{}, Buckets: []string{}} data, err := json.Marshal(empty) if err != nil { t.Fatal(err) @@ -223,13 +248,16 @@ func TestRepositoryRootAdmission(t *testing.T) { id := strings.Repeat("a", format.HexSize()) valid := rootMeta{Validated: true, Format: format, Head: "refs/heads/main", Refs: map[string]string{"refs/heads/main": id}, Buckets: []string{"0a", "ab"}} invalid := map[string]rootMeta{ - "head": {Validated: true, Format: format, Head: "refs/tags/main", Refs: valid.Refs, Buckets: valid.Buckets}, - "reference": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"HEAD": id}, Buckets: valid.Buckets}, - "hash": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": strings.ToUpper(id)}, Buckets: valid.Buckets}, - "zero hash": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": strings.Repeat("0", format.HexSize())}, Buckets: valid.Buckets}, - "ref collision": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": id, "refs/heads/main/nested": id}, Buckets: valid.Buckets}, - "bucket order": {Validated: true, Format: format, Head: valid.Head, Refs: valid.Refs, Buckets: []string{"ab", "0a"}}, - "bucket encoding": {Validated: true, Format: format, Head: valid.Head, Refs: valid.Refs, Buckets: []string{"AZ"}}, + "empty head": {Validated: true, Format: format, Refs: valid.Refs, Buckets: valid.Buckets}, + "head": {Validated: true, Format: format, Head: "refs/tags/main", Refs: valid.Refs, Buckets: valid.Buckets}, + "unsafe head": {Validated: true, Format: format, Head: "refs/heads/\u200c./review-probe", Refs: valid.Refs, Buckets: valid.Buckets}, + "reference": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"HEAD": id}, Buckets: valid.Buckets}, + "unsafe reference": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/\u200c./review-probe": id}, Buckets: valid.Buckets}, + "hash": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": strings.ToUpper(id)}, Buckets: valid.Buckets}, + "zero hash": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": strings.Repeat("0", format.HexSize())}, Buckets: valid.Buckets}, + "ref collision": {Validated: true, Format: format, Head: valid.Head, Refs: map[string]string{"refs/heads/main": id, "refs/heads/main/nested": id}, Buckets: valid.Buckets}, + "bucket order": {Validated: true, Format: format, Head: valid.Head, Refs: valid.Refs, Buckets: []string{"ab", "0a"}}, + "bucket encoding": {Validated: true, Format: format, Head: valid.Head, Refs: valid.Refs, Buckets: []string{"AZ"}}, } data, err = json.Marshal(valid) if err != nil { @@ -239,6 +267,7 @@ func TestRepositoryRootAdmission(t *testing.T) { t.Fatalf("rejected valid root: %v", err) } for name, malformed := range map[string][]byte{ + "missing head": []byte(strings.Replace(string(data), `"Head":"refs/heads/main",`, "", 1)), "unknown field": []byte(strings.Replace(string(data), `"Buckets":`, `"Unknown":true,"Buckets":`, 1)), "trailing value": append(append([]byte(nil), data...), []byte(`{}`)...), } { @@ -269,7 +298,7 @@ func TestRepositoryRootAdmission(t *testing.T) { {"validated", true, true}, } { t.Run(test.name, func(t *testing.T) { - root := map[string]any{"Format": format, "Buckets": []string{"ab"}} + root := map[string]any{"Format": format, "Head": "refs/heads/main", "Buckets": []string{"ab"}} if test.marker != nil { root["Validated"] = test.marker } diff --git a/examples/git/main.go b/examples/git/main.go index 2be44cc..2b2bf21 100644 --- a/examples/git/main.go +++ b/examples/git/main.go @@ -40,15 +40,21 @@ func sessionRetention(session *wal.Session) (retentionCall, retentionCall) { } } -func advertise(w io.Writer, s *store) error { +func advertise(w io.Writer, s *store, unknownFormat bool) error { if err := (&packp.SmartReply{Service: transport.ReceivePackService}).Encode(w); err != nil { return err } adv := &packp.AdvRefs{} - for _, feature := range []string{capability.ReportStatus, capability.DeleteRefs, capability.OFSDelta, capability.Atomic, capability.NoThin} { + for _, feature := range []string{ + capability.ReportStatus, capability.DeleteRefs, capability.OFSDelta, + capability.Atomic, capability.NoThin, capability.Sideband64k, capability.Quiet, + } { adv.Capabilities.Add(feature) } adv.Capabilities.Set(capability.ObjectFormat, s.meta.Format.String()) + if unknownFormat { + adv.Capabilities.Set(capability.ObjectFormat, "sha1", "sha256") + } for _, name := range slices.Sorted(maps.Keys(s.meta.Refs)) { id := s.meta.Refs[name] adv.References = append(adv.References, plumbing.NewHashReference(plumbing.ReferenceName(name), plumbing.NewHash(id))) @@ -135,7 +141,6 @@ func serve(response http.ResponseWriter, r *http.Request) { service, method := route.Service, route.Method maintenance := service == "maintenance" collect := service == "collect" - pruneInvalidRefs := service == "prune-invalid-refs" recoverRetentions := service == "recover-retentions-after-drain" limits, e := loadLimits(getConfig) if e != nil { @@ -150,7 +155,7 @@ func serve(response http.ResponseWriter, r *http.Request) { http.Error(response, message, status) return } - if limits.readOnly && (service == transport.ReceivePackService || maintenance || collect || pruneInvalidRefs) { + if limits.readOnly && (route.Action == gitWrite || maintenance || collect) { http.Error(response, "repository is read-only", http.StatusForbidden) return } @@ -164,19 +169,53 @@ func serve(response http.ResponseWriter, r *http.Request) { http.Error(response, err.Error(), operationStatus(err)) return } + var pushCapabilities *capability.List + if service == transport.ReceivePackService && method == http.MethodPost { + format, capabilities, body, err := receiveFormat(r.Body, limits.negotiationBytes) + if err == nil && format != "" && route.Repository.Format != "" && route.Repository.Format != format { + err = config.ErrInvalidObjectFormat + } + if err != nil { + status := operationStatus(err) + if status == http.StatusInternalServerError { + status = http.StatusBadRequest + } + http.Error(response, "invalid push format", status) + return + } + if format == "" { + response.Header().Set("Content-Type", "application/x-git-receive-pack-result") + log.Printf("wal %s %s id=%s calls=0 bytes=0", r.Method, r.URL.Path, requestID) + response.WriteHeader(http.StatusOK) + return + } + route.Repository.Format = format + pushCapabilities = capabilities + r.Body = gitio.NewReadCloser(body, r.Body) + } + settings, e := walSettings(getConfig, route.Repository.LogID, limits) if e != nil { http.Error(response, e.Error(), http.StatusInternalServerError) return } session, e := unwrap(func() wt.Result[*wal.Session, wal.Failure] { - // First-push discovery requires an empty repository advertisement. Only - // a configured repository and an authorized writer may create its head. - if route.Action == gitWrite { + if service == transport.ReceivePackService && method == http.MethodPost { return wal.Open(settings) } return wal.OpenExisting(settings) }) + if errors.Is(e, errLogMissing) && service == transport.ReceivePackService && method == http.MethodGet { + response.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement") + format := route.Repository.Format + if format == "" { + format = config.SHA1 + } + if err := advertise(response, &store{meta: rootMeta{Format: format}}, route.Repository.Format == ""); err != nil { + log.Printf("git discovery failed: %v", err) + } + return + } if e != nil { http.Error(response, e.Error(), operationStatus(e)) return @@ -274,7 +313,11 @@ func serve(response http.ResponseWriter, r *http.Request) { s, e := retryOpenStore(open, refresh) if e != nil { log.Printf("git request setup failed stage=open-store: %v", e) - http.Error(w, "Git storage unavailable", operationStatus(e)) + status := operationStatus(e) + if errors.Is(e, config.ErrInvalidObjectFormat) { + status = http.StatusBadRequest + } + http.Error(w, "Git storage unavailable", status) return } defer func() { @@ -282,28 +325,9 @@ func serve(response http.ResponseWriter, r *http.Request) { s.Close() } }() - if s.stateRoot == nil { - if route.Action != gitWrite { - http.NotFound(w, r) - return - } - // Persist the format and default branch before the first advertisement. - // A competing creator must reload the winning root before proceeding. - if err := s.publish(s.meta.Refs); err != nil && !errors.Is(err, errPublicationConflict) { - http.Error(w, "repository initialization failed", operationStatus(err)) - return - } - s.Close() - if e = refresh(); e == nil { - s, e = retryOpenStore(open, refresh) - } - if e == nil && s.stateRoot == nil { - e = errPublicationConflict - } - if e != nil { - http.Error(w, "repository initialization failed", operationStatus(e)) - return - } + if s.stateRoot == nil && service != transport.ReceivePackService { + http.NotFound(w, r) + return } if service == transport.ReceivePackService && method == http.MethodPost { e = retryBeforePush(func() (bool, error) { return s.beforePush() }, func() error { @@ -328,55 +352,39 @@ func serve(response http.ResponseWriter, r *http.Request) { report, err := s.maintain() writeMaintenance(w, report, err) return - } else if pruneInvalidRefs { - if len(r.Header.Values("X-Git-Replacement-Head")) > 1 { - http.Error(w, "multiple replacement HEAD values", http.StatusBadRequest) - return - } - clean, removed, err := withoutInvalidRefs(s.meta, r.Header.Get("X-Git-Replacement-Head")) - if err != nil { - http.Error(w, err.Error(), http.StatusConflict) - return - } - newHead := "" - if clean.Head != s.meta.Head { - newHead = clean.Head - } - if len(removed) != 0 || newHead != "" { - s.meta.Head = clean.Head - if err := s.publish(clean.Refs); err != nil { - status := operationStatus(err) - var pending *pendingError - if errors.Is(err, errPublicationConflict) { - status = http.StatusConflict - } else if errors.As(err, &pending) { - status = http.StatusServiceUnavailable - } - http.Error(w, "ref cleanup was not confirmed: "+err.Error(), status) - return - } - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(struct { - Removed []string `json:"removed_refs"` - Head string `json:"default_head,omitempty"` - }{removed, newHead}) - return } else if service == transport.ReceivePackService && method == http.MethodGet { w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement") - e = advertise(w, s) + e = advertise(w, s, s.stateRoot == nil && route.Repository.Format == "") } else if service == transport.ReceivePackService { w.Header().Set("Content-Type", "application/x-git-receive-pack-result") + s.progress = newReceiveProgress(w, pushCapabilities, cancel) commands := &commandReader{LimitedReader: io.LimitedReader{R: r.Body, N: limits.negotiationBytes}} push := &receiveStore{Storer: s, commands: commands} e = transport.ReceivePack(r.Context(), push, gitio.NewReadCloser(commands, r.Body), gitio.WriteNopCloser(w), &transport.ReceivePackRequest{StatelessRPC: true, Hooks: transport.ReceivePackHooks{PreReceive: func(_ context.Context, info *transport.PreReceiveInfo) error { if len(info.Commands) == 0 { return nil } + if s.progress != nil { + s.progress.writer = info.Progress + } + if err := s.progress.message("Validating update...\n"); err != nil { + return err + } refs, e := validate(s, info.Commands) if e != nil { return e } + if s.stateRoot == nil && route.Repository.DefaultBranch == "" { + for _, command := range info.Commands { + if command.Action() != packp.Delete && command.Name.IsBranch() { + s.meta.Head = command.Name.String() + break + } + } + } + if err := s.progress.message("Publishing update...\n"); err != nil { + return err + } e = s.publish(refs) return e }}}) diff --git a/examples/git/qualification/aws/HOSTING.md b/examples/git/qualification/aws/HOSTING.md index 2bcffd1..fb72b41 100644 --- a/examples/git/qualification/aws/HOSTING.md +++ b/examples/git/qualification/aws/HOSTING.md @@ -30,9 +30,11 @@ use that larger limit only on a host with measured headroom. `host_repositories` provisions canonical names, unique stable WAL IDs, SHA-1 or SHA-256 formats, default branches, and independent read/write/admin groups. Declare all referenced groups in `host_groups`. Users and membership are managed -in Cognito outside Terraform; self-registration is disabled. Adding a repository -requires configuration, not rebuilding. An authorized first-push discovery -materializes it. Do not change a stored repository's format or identity to migrate it. +in Cognito outside Terraform; self-registration is disabled. Named policies +require a configuration change to admit new paths; a wildcard policy admits +new names without one. The first successful authorized push creates the +repository. Do not change a stored repository's format or +identity to migrate it. The artifact is staged under this run's `artifacts/` prefix and SHA-256 checked at boot. Data uses the separate `git/` prefix. The EC2 role can read that artifact @@ -132,9 +134,10 @@ git config --global --add credential.https://git.example.com.helper oauth git clone https://git.example.com/team/project.git ``` -A fresh configured repository returns 404 to reads until a writer's first push -materializes it. Push from a local repository with the configured object format -and initial branch before trying to clone it. +A fresh repository returns 404 on reads until an authorized writer pushes its +first branch. That push records the Git format and refs together. An optional +policy format must match the local repository; use a matching repository before +trying to clone it. The helper uses authorization-code S256 PKCE and sends the access token as an ordinary Basic password. Its fixed `http://localhost:53119` callback matches the diff --git a/examples/git/qualification/aws/hosting.tf b/examples/git/qualification/aws/hosting.tf index f94f928..196f0f9 100644 --- a/examples/git/qualification/aws/hosting.tf +++ b/examples/git/qualification/aws/hosting.tf @@ -49,10 +49,10 @@ variable "host_git_max_object_bytes" { } variable "host_repositories" { - description = "Explicit repository provisioning and independent group permissions." + description = "Repository policies; '*' supplies defaults for API-created repositories." type = map(object({ - log_id = string - format = string + log_id = optional(string, "") + format = optional(string, "") default_branch = optional(string, "main") read_groups = optional(list(string), []) write_groups = optional(list(string), []) @@ -60,8 +60,8 @@ variable "host_repositories" { })) default = {} validation { - condition = alltrue([for repo in var.host_repositories : contains(["sha1", "sha256"], repo.format)]) - error_message = "Repository format must be sha1 or sha256." + condition = alltrue([for repo in var.host_repositories : contains(["", "sha1", "sha256"], repo.format)]) + error_message = "Repository format must be empty, sha1 or sha256." } } @@ -183,7 +183,7 @@ locals { token_url = "${local.cognito_login_origin}/oauth2/token" service_url = "http://127.0.0.1:8081" validation_url = "http://127.0.0.1:3000/_validate_backend" - repositories = sort(keys(var.host_repositories)) + repositories = sort([for name in keys(var.host_repositories) : name if name != "*"]) budget_seconds = var.host_maintenance_budget_seconds pause_seconds = var.host_maintenance_pause_seconds })) @@ -191,8 +191,8 @@ locals { run_timeout = length(var.host_repositories) * var.host_maintenance_budget_seconds + 65 retry_after = max(1, length(var.host_repositories) * var.host_maintenance_pause_seconds) admin_paths = jsonencode(flatten([for name in sort(keys(var.host_repositories)) : [ - for operation in ["maintenance", "collect", "prune-invalid-refs", "recover-retentions-after-drain"] : "/${name}/${operation}" - ]])) + for operation in ["maintenance", "collect", "recover-retentions-after-drain"] : "/${name}/${operation}" + ] if name != "*"])) }) : "" } diff --git a/examples/git/qualification/aws/maintenance.py b/examples/git/qualification/aws/maintenance.py index 598f266..c8a1324 100644 --- a/examples/git/qualification/aws/maintenance.py +++ b/examples/git/qualification/aws/maintenance.py @@ -82,7 +82,7 @@ def post(self, repository, operation, deadline): url, data=b"", headers={"Authorization": f"Bearer {token}"}, ), deadline)["state"] except urllib.error.HTTPError as error: - # Configured repositories are materialized by their first writer. + # A configured repository may not yet have been created. if error.code == 404: error.close() return "not materialized" diff --git a/examples/git/qualification/aws/test_admission.py b/examples/git/qualification/aws/test_admission.py index 4f17ab9..d429f0d 100644 --- a/examples/git/qualification/aws/test_admission.py +++ b/examples/git/qualification/aws/test_admission.py @@ -59,7 +59,7 @@ def log_message(self, *_): def handle_request(self): self.rfile.read(int(self.headers.get("Content-Length", 0))) Backend.calls.append((self.command, self.path)) - admin = self.path.endswith(("/maintenance", "/collect", "/prune-invalid-refs")) + admin = self.path.endswith(("/maintenance", "/collect")) status = 401 if admin and self.headers.get("Authorization") != "Bearer fixture" else 200 data = b"git" if self.path == "/_validate_backend": @@ -110,7 +110,7 @@ def setUpClass(cls): # This local HTTP fixture omits the public-IP certificate configuration. snippet = re.sub(r"%\{ if public_ip_https ~\}.*?%\{ endif ~\}\n", "", snippet, flags=re.S) paths = [f"/{name}/{operation}" for name in ("alpha/project.git", "star*project.git") - for operation in ("maintenance", "collect", "prune-invalid-refs", "recover-retentions-after-drain")] + for operation in ("maintenance", "collect", "recover-retentions-after-drain")] snippet = snippet.replace("${hostname}", "http://:8080") snippet = snippet.replace("http://127.0.0.1:8081", "http://:8081") snippet = snippet.replace("${admin_paths}", json.dumps(paths)).replace("${retry_after}", "2") @@ -201,7 +201,7 @@ def test_existing_request_survives_and_admin_paths_are_exact(self): ("POST", "/starXproject.git/maintenance")]: self.assertEqual(self.request(method, path)[0], 503, path) for path in ("/alpha/project.git/maintenance", "/alpha/project.git/collect", - "/alpha/project.git/prune-invalid-refs", "/star*project.git/maintenance"): + "/star*project.git/maintenance"): self.assertEqual(self.request("POST", path)[0], 401, path) self.assertEqual(self.request("POST", path, {"Authorization": "Bearer fixture"})[0], 200, path) Backend.release.set() diff --git a/examples/git/receive.go b/examples/git/receive.go index f246b66..7689871 100644 --- a/examples/git/receive.go +++ b/examples/git/receive.go @@ -46,6 +46,9 @@ func (p *incomingPack) Close() (err error) { if p.err != nil { return p.err } + if err := p.s.progress.message("Checking received pack...\n"); err != nil { + return err + } root, err := p.finish() if err != nil { return err @@ -58,7 +61,10 @@ func (p *incomingPack) Close() (err error) { } defer reader.Close() offsets := packOffsets{} - if err := importPack(p.s.ctx, reader, p.s, p.s.meta.Format, p.s.limits, offsets); err != nil { + if err := importPack(p.s.ctx, reader, p.s, p.s.meta.Format, p.s.limits, offsets, p.s.progress); err != nil { + return err + } + if err := p.s.progress.message("Indexing deltas...\n"); err != nil { return err } remaining := p.s.limits.catalogBytes diff --git a/examples/git/receive_progress.go b/examples/git/receive_progress.go new file mode 100644 index 0000000..bd9adba --- /dev/null +++ b/examples/git/receive_progress.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/protocol/capability" + "github.com/go-git/go-git/v6/plumbing/protocol/packp/sideband" +) + +// ReceivePack creates its hook progress writer after pack import. Use the same +// upstream mux for import, then its hook writer for validation and publication. +type receiveProgress struct { + writer io.Writer + cancel context.CancelFunc + total, done uint32 + last time.Time + err error +} + +func newReceiveProgress(w io.Writer, caps *capability.List, cancel context.CancelFunc) *receiveProgress { + if caps == nil || !caps.Supports(capability.Sideband64k) { + return nil + } + if caps.Supports(capability.Quiet) || caps.Supports(capability.NoProgress) { + return nil + } + return &receiveProgress{ + writer: progressChannel{mux: sideband.NewMuxer(sideband.Sideband64k, w)}, + cancel: cancel, + } +} + +type progressChannel struct{ mux *sideband.Muxer } + +func (w progressChannel) Write(data []byte) (int, error) { + return w.mux.WriteChannel(sideband.ProgressMessage, data) +} + +func (p *receiveProgress) message(text string) error { + if p == nil { + return nil + } + if p.err == nil { + _, p.err = io.WriteString(p.writer, text) + if p.err != nil && p.cancel != nil { + p.cancel() + } + p.last = time.Now() + } + return p.err +} + +func (p *receiveProgress) OnHeader(total uint32) error { + if p == nil { + return nil + } + p.total = total + return p.message(fmt.Sprintf("Importing objects: 0/%d\n", total)) +} + +func (p *receiveProgress) OnInflatedObjectHeader(plumbing.ObjectType, int64, int64) error { + if p == nil { + return nil + } + return p.err +} + +func (p *receiveProgress) OnInflatedObjectContent(plumbing.Hash, int64, uint32, []byte) error { + if p == nil { + return nil + } + p.done++ + if time.Since(p.last) < time.Second { + return p.err + } + return p.message(fmt.Sprintf("Importing objects: %d/%d\r", p.done, p.total)) +} + +func (p *receiveProgress) OnFooter(plumbing.Hash) error { + if p == nil { + return nil + } + return p.message(fmt.Sprintf("Importing objects: %d/%d\n", p.done, p.total)) +} diff --git a/examples/git/receive_progress_test.go b/examples/git/receive_progress_test.go new file mode 100644 index 0000000..2cbd08e --- /dev/null +++ b/examples/git/receive_progress_test.go @@ -0,0 +1,299 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/format/config" + "github.com/go-git/go-git/v6/plumbing/format/pktline" + "github.com/go-git/go-git/v6/plumbing/protocol/capability" + "github.com/go-git/go-git/v6/plumbing/protocol/packp" + "github.com/go-git/go-git/v6/plumbing/protocol/packp/sideband" + "github.com/go-git/go-git/v6/plumbing/transport" + "github.com/go-git/go-git/v6/storage/memory" + gitio "github.com/go-git/go-git/v6/utils/ioutil" +) + +func TestReceiveProgressNegotiation(t *testing.T) { + for _, tc := range []struct { + name, caps string + progress bool + }{ + {name: "unnegotiated", caps: "report-status"}, + {name: "quiet", caps: "side-band-64k quiet"}, + {name: "no progress", caps: "side-band-64k no-progress"}, + {name: "sideband 64k", caps: "side-band-64k", progress: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + var caps capability.List + capability.DecodeList([]byte(tc.caps), &caps) + progress := newReceiveProgress(&output, &caps, nil) + if err := progress.message("Checking received pack...\n"); err != nil { + t.Fatal(err) + } + if !tc.progress { + if output.Len() != 0 { + t.Fatalf("unrequested progress: %q", output.String()) + } + return + } + scanner := pktline.NewScanner(&output) + if !scanner.Scan() || string(scanner.Bytes()) != "\x02Checking received pack...\n" { + t.Fatalf("invalid progress packet: %q, %v", scanner.Bytes(), scanner.Err()) + } + if scanner.Scan() || scanner.Err() != nil { + t.Fatalf("progress terminated the receive response: %v", scanner.Err()) + } + }) + } +} + +func TestReceiveProgressCountsImportedDeltaObjects(t *testing.T) { + for _, format := range []config.ObjectFormat{config.SHA1, config.SHA256} { + t.Run(format.String(), func(t *testing.T) { + var output bytes.Buffer + progress := &receiveProgress{writer: &output} + pack := fixturePack(t, format, []packFixtureEntry{ + {kind: plumbing.BlobObject, data: []byte("abc")}, + {kind: plumbing.OFSDeltaObject, data: []byte{3, 1, 0x90, 1}, ofs: 0}, + {kind: plumbing.REFDeltaObject, data: []byte{1, 1, 1, 'b'}, ref: blobID(format, []byte("a"))}, + }) + if err := importPack(t.Context(), bytes.NewReader(pack), newImportStorage(format), format, testPackLimits(1024), progress); err != nil { + t.Fatal(err) + } + if output.String() != "Importing objects: 0/3\nImporting objects: 3/3\n" { + t.Fatalf("inaccurate imported-object progress: %q", output.String()) + } + }) + } +} + +func TestReceiveProgressThrottlesObjectMessages(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var output bytes.Buffer + progress := &receiveProgress{writer: &output} + if err := progress.OnHeader(3); err != nil { + t.Fatal(err) + } + if err := progress.OnInflatedObjectContent(plumbing.ZeroHash, 0, 0, nil); err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + if err := progress.OnInflatedObjectContent(plumbing.ZeroHash, 0, 0, nil); err != nil { + t.Fatal(err) + } + if output.String() != "Importing objects: 0/3\nImporting objects: 2/3\r" { + t.Fatalf("progress cadence: %q", output.String()) + } + }) +} + +type failedProgress struct { + err error + writes int +} + +func (w *failedProgress) Write([]byte) (int, error) { + w.writes++ + return 0, w.err +} + +type cancellableImportStorage struct { + *importStorage + ctx context.Context +} + +func (s *cancellableImportStorage) RawObjectWriter(kind plumbing.ObjectType, size int64) (io.WriteCloser, error) { + if err := s.ctx.Err(); err != nil { + return nil, err + } + return s.importStorage.RawObjectWriter(kind, size) +} + +func TestReceiveProgressWriteFailureStopsImport(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + failure := errors.New("response closed") + output := &failedProgress{err: failure} + progress := &receiveProgress{writer: output, cancel: cancel} + st := &cancellableImportStorage{importStorage: newImportStorage(config.SHA1), ctx: ctx} + pack := fixturePack(t, config.SHA1, []packFixtureEntry{ + {kind: plumbing.BlobObject, data: []byte("abc")}, + {kind: plumbing.BlobObject, data: []byte("def")}, + }) + err := importPack(ctx, bytes.NewReader(pack), st, config.SHA1, testPackLimits(1024), progress) + // Cancellation stops storage work even when upstream ignores callback errors. + if err == nil || ctx.Err() != context.Canceled || st.writes != 0 { + t.Fatalf("import continued: err=%v context=%v object writes=%d", err, ctx.Err(), st.writes) + } + if !errors.Is(progress.err, failure) || output.writes != 1 { + t.Fatalf("progress write error lost: err=%v writes=%d", progress.err, output.writes) + } + if err := progress.message("Publishing update...\n"); !errors.Is(err, failure) || output.writes != 1 { + t.Fatalf("progress retried a failed writer: err=%v writes=%d", err, output.writes) + } +} + +type progressPackStorage struct { + *memory.Storage + closePack func() error +} + +func (s *progressPackStorage) PackfileWriter() (io.WriteCloser, error) { + return gitio.NewWriteCloser(io.Discard, progressPackClose{s.closePack}), nil +} + +type progressPackClose struct{ close func() error } + +func (c progressPackClose) Close() error { return c.close() } + +func TestReceiveProgressPreservesReportStatus(t *testing.T) { + for _, tc := range []struct { + name, reason string + caps string + unpackError bool + plain bool + }{ + {name: "committed"}, + {name: "validation rejected", reason: "stale ref"}, + {name: "publication uncertain", reason: "publication pending"}, + {name: "pack rejected", reason: "malformed pack", unpackError: true}, + {name: "quiet committed", caps: "report-status side-band-64k quiet"}, + {name: "quiet uncertain", caps: "report-status side-band-64k quiet", reason: "publication pending"}, + {name: "plain committed", caps: "report-status", plain: true}, + {name: "plain uncertain", caps: "report-status", reason: "publication pending", plain: true}, + {name: "no progress", caps: "report-status side-band-64k no-progress", plain: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + var caps capability.List + features := tc.caps + if features == "" { + features = "report-status side-band-64k" + } + capability.DecodeList([]byte(features), &caps) + progress := newReceiveProgress(&output, &caps, nil) + st := &progressPackStorage{Storage: memory.NewStorage()} + st.closePack = func() error { + if err := progress.message("Importing objects...\n"); err != nil { + return err + } + if tc.unpackError { + return errors.New(tc.reason) + } + return nil + } + object := plumbing.NewMemoryObject(plumbing.FromObjectFormat(config.SHA1)) + object.SetType(plumbing.BlobObject) + writer, _ := object.Writer() + _, _ = io.WriteString(writer, "progress test") + _ = writer.Close() + tip, err := st.SetEncodedObject(object) + if err != nil { + t.Fatal(err) + } + request := packetForTest(strings.Repeat("0", 40) + " " + tip.String() + " refs/tags/progress\x00" + caps.String() + "\n") + request = append(request, []byte("0000pack bytes")...) + hookCalled := false + err = transport.ReceivePack( + t.Context(), + st, + io.NopCloser(bytes.NewReader(request)), + gitio.WriteNopCloser(&output), + &transport.ReceivePackRequest{StatelessRPC: true, Hooks: transport.ReceivePackHooks{ + PreReceive: func(_ context.Context, info *transport.PreReceiveInfo) error { + hookCalled = true + if progress != nil { + progress.writer = info.Progress + } + if err := progress.message("Publishing update...\n"); err != nil { + return err + } + if tc.reason != "" { + return errors.New(tc.reason) + } + return nil + }, + }}, + ) + if (err == nil) != (tc.reason == "") || hookCalled == tc.unpackError { + t.Fatalf("receive outcome: err=%v hooked=%t", err, hookCalled) + } + var messages bytes.Buffer + reportReader := io.Reader(bytes.NewReader(output.Bytes())) + if !tc.plain { + if !bytes.HasSuffix(output.Bytes(), []byte("0009\x0100000000")) { + t.Fatalf("sideband termination changed: %q", output.String()) + } + demux := sideband.NewDemuxer(sideband.Sideband64k, reportReader) + demux.Progress = &messages + reportReader = demux + } + var report packp.ReportStatus + if err := report.Decode(reportReader); err != nil { + t.Fatal(err) + } + if strings.Contains(messages.String(), "Importing objects") != (progress != nil) { + t.Fatalf("unexpected import feedback: %q", messages.String()) + } + if tc.unpackError { + if report.UnpackStatus == "ok" || len(report.CommandStatuses) != 0 { + t.Fatalf("pack failure reported success: %+v", report) + } + return + } + status := report.CommandStatuses[0] + want := "ok" + if tc.reason != "" { + want = tc.reason + } + if status.Status != want || status.ReferenceName != "refs/tags/progress" { + t.Fatalf("publication status changed: %+v", status) + } + ref, refErr := st.Reference("refs/tags/progress") + if tc.reason == "" && (refErr != nil || ref.Hash() != tip) { + t.Fatalf("committed ref missing: %v", refErr) + } + if tc.reason != "" && !errors.Is(refErr, plumbing.ErrReferenceNotFound) { + t.Fatalf("rejected or uncertain update moved a ref: %v", ref) + } + }) + } +} + +func TestReceiveProgressWriteFailurePreventsPublication(t *testing.T) { + failure := errors.New("response closed") + var caps capability.List + capability.DecodeList([]byte("report-status side-band-64k"), &caps) + output := &failedProgress{err: failure} + progress := newReceiveProgress(output, &caps, nil) + st := &progressPackStorage{Storage: memory.NewStorage(), closePack: func() error { + return progress.message("Checking received pack...\n") + }} + request := packetForTest(strings.Repeat("0", 40) + " " + strings.Repeat("1", 40) + " refs/heads/main\x00" + caps.String() + "\n") + request = append(request, []byte("0000pack bytes")...) + hooked := false + err := transport.ReceivePack( + t.Context(), + st, + io.NopCloser(bytes.NewReader(request)), + gitio.WriteNopCloser(output), + &transport.ReceivePackRequest{StatelessRPC: true, Hooks: transport.ReceivePackHooks{ + PreReceive: func(context.Context, *transport.PreReceiveInfo) error { + hooked = true + return nil + }, + }}, + ) + if !errors.Is(err, failure) || hooked { + t.Fatalf("progress failure reached publication: error=%v hook=%t", err, hooked) + } +} diff --git a/examples/git/receive_transport.go b/examples/git/receive_transport.go index e904a58..c1d748a 100644 --- a/examples/git/receive_transport.go +++ b/examples/git/receive_transport.go @@ -1,14 +1,62 @@ package main import ( + "bytes" "fmt" "io" "math" + "strings" + "github.com/go-git/go-git/v6/plumbing/format/config" + "github.com/go-git/go-git/v6/plumbing/format/pktline" + "github.com/go-git/go-git/v6/plumbing/protocol/capability" + "github.com/go-git/go-git/v6/plumbing/protocol/packp" "github.com/go-git/go-git/v6/plumbing/storer" "github.com/go-git/go-git/v6/storage" ) +// Decode the first command with go-git, then replay its bounded prefix so the +// ordinary receive-pack path still validates every command and the entire pack. +func receiveFormat(body io.Reader, limit int64) (config.ObjectFormat, *capability.List, io.Reader, error) { + var prefix bytes.Buffer + scanner := pktline.NewScanner(&commandReader{io.LimitedReader{R: io.TeeReader(body, &prefix), N: limit}}) + for scanner.Scan() { + if scanner.Len() == pktline.Flush || bytes.IndexByte(scanner.Bytes(), 0) >= 0 { + break + } + } + if err := scanner.Err(); err != nil { + return "", nil, nil, err + } + replay := io.MultiReader(bytes.NewReader(prefix.Bytes()), body) + if prefix.Len() == 4 && scanner.Len() == pktline.Flush { + return "", nil, replay, nil + } + request := &packp.UpdateRequests{} + if err := request.Decode(io.MultiReader(bytes.NewReader(prefix.Bytes()), strings.NewReader("0000"))); err != nil { + return "", nil, nil, err + } + if len(request.Commands) == 0 { + return "", nil, replay, nil + } + formats := request.Capabilities.Get(capability.ObjectFormat) + format := config.SHA1 + if request.Capabilities.Supports(capability.ObjectFormat) && len(formats) != 1 { + return "", nil, nil, config.ErrInvalidObjectFormat + } + if len(formats) == 1 { + format = config.ObjectFormat(formats[0]) + } + if format != config.SHA1 && format != config.SHA256 { + return "", nil, nil, config.ErrInvalidObjectFormat + } + command := request.Commands[0] + if command.Old.HexSize() != format.HexSize() || command.New.HexSize() != format.HexSize() { + return "", nil, nil, config.ErrInvalidObjectFormat + } + return format, &request.Capabilities, replay, nil +} + // ReceivePack opens the pack writer after decoding commands and push options. // Release their smaller read limit; the HTTP body retains the total push limit. type receiveStore struct { diff --git a/examples/git/receive_transport_test.go b/examples/git/receive_transport_test.go index a7d39f7..7d92518 100644 --- a/examples/git/receive_transport_test.go +++ b/examples/git/receive_transport_test.go @@ -73,3 +73,65 @@ func TestReceiveCommandLimit(t *testing.T) { } } } + +func TestReceiveFormat(t *testing.T) { + for _, tc := range []struct { + name, caps string + width int + want config.ObjectFormat + }{ + {"legacy SHA-1", "report-status", 40, config.SHA1}, + {"SHA-1", "object-format=sha1", 40, config.SHA1}, + {"SHA-256", "object-format=sha256", 64, config.SHA256}, + {"missing SHA-256 capability", "report-status", 64, ""}, + {"wrong width", "object-format=sha256", 40, ""}, + {"unknown format", "object-format=other", 40, ""}, + {"missing format", "object-format", 40, ""}, + {"multiple formats", "object-format=sha1 object-format=sha256", 40, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var prefix bytes.Buffer + _, err := pktline.Writef(&prefix, "%s %s refs/heads/main\x00%s\n", strings.Repeat("0", tc.width), strings.Repeat("1", tc.width), tc.caps) + if err != nil { + t.Fatal(err) + } + packet := bytes.Clone(prefix.Bytes()) + input := append(bytes.Clone(packet), []byte("0000PACKpayload")...) + body := bytes.NewBuffer(input) + format, caps, replay, err := receiveFormat(body, int64(len(packet))) + if tc.want == "" { + if err == nil { + t.Fatal("invalid format accepted") + } + return + } + if err != nil || format != tc.want || body.Len() != len("0000PACKpayload") { + t.Fatalf("format=%s error=%v unread=%d", format, err, body.Len()) + } + if caps.String() != tc.caps { + t.Fatalf("capabilities changed: %s", caps.String()) + } + data, err := io.ReadAll(replay) + if err != nil || !bytes.Equal(data, input) { + t.Fatalf("push body was changed: %v", err) + } + if _, _, _, err := receiveFormat(bytes.NewReader(input), int64(len(packet)-1)); !errors.Is(err, errObjectLimit) { + t.Fatalf("negotiation limit: %v", err) + } + }) + } + for _, input := range []string{"0000", string(packetForTest("shallow "+strings.Repeat("1", 40)+"\n")) + "0000"} { + format, caps, replay, err := receiveFormat(strings.NewReader(input), int64(len(input))) + if err != nil || format != "" || caps != nil { + t.Fatalf("no-op push: format=%s error=%v", format, err) + } + data, err := io.ReadAll(replay) + if err != nil || string(data) != input { + t.Fatalf("no-op body was changed: %v", err) + } + } +} + +func packetForTest(text string) []byte { + return []byte(fmt.Sprintf("%04x%s", len(text)+4, text)) +} diff --git a/examples/git/repositories.go b/examples/git/repositories.go index aa4a317..58c58dd 100644 --- a/examples/git/repositories.go +++ b/examples/git/repositories.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -12,9 +13,11 @@ import ( "strings" "unicode/utf8" + "github.com/go-git/go-billy/v6/memfs" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/format/config" "github.com/go-git/go-git/v6/plumbing/transport" + "github.com/go-git/go-git/v6/storage/filesystem/dotgit" ) const repositoriesConfigBytes = 64 << 10 @@ -26,8 +29,8 @@ var ( repositoryLogID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) ) -// A map entry provisions a name. Opening or creating storage remains the caller's -// responsibility, after checking the route's action against repositoryAccess. +// A map entry permits a name. Opening storage remains the caller's responsibility, +// after checking the route's action against repositoryAccess. type repositoryConfig struct { LogID string `json:"log_id"` Format config.ObjectFormat `json:"format"` @@ -35,8 +38,7 @@ type repositoryConfig struct { repositoryAccess } -// GIT_REPOSITORIES is an object keyed by canonical names such as team/project.git. -// Missing configuration provides no repositories. There are no implicit aliases. +// Exact policies override "*". Bare names and their .git URLs identify one log. func loadRepositories(getenv func(string) string) (map[string]repositoryConfig, error) { text := getenv("GIT_REPOSITORIES") if len(text) > repositoriesConfigBytes || !utf8.ValidString(text) { @@ -52,17 +54,32 @@ func loadRepositories(getenv func(string) string) (map[string]repositoryConfig, } identities := map[string]string{} for name, encoded := range entries { - if !validRepositoryName(name) { + if name != "*" && !validRepositoryName(name) { return nil, fmt.Errorf("GIT_REPOSITORIES: noncanonical repository name %q", name) } + if name != "*" { + name = canonicalRepositoryName(name) + } + if _, exists := repositories[name]; exists { + return nil, fmt.Errorf("duplicate repository name %q", name) + } repository, err := parseRepository(encoded) if err != nil { return nil, fmt.Errorf("GIT_REPOSITORIES: repository %q: %w", name, err) } - if other, exists := identities[repository.LogID]; exists { - return nil, fmt.Errorf("repositories %q and %q share log_id %q", other, name, repository.LogID) + if name == "*" { + if repository.LogID != "" { + return nil, errors.New("wildcard policy must not specify log_id") + } + } else { + if repository.LogID == "" { + repository.LogID = automaticRepositoryID(name) + } + if other, exists := identities[repository.LogID]; exists { + return nil, fmt.Errorf("repositories %q and %q share log_id %q", other, name, repository.LogID) + } + identities[repository.LogID] = name } - identities[repository.LogID] = name repositories[name] = repository } return repositories, nil @@ -88,14 +105,19 @@ func parseRepository(encoded []byte) (repositoryConfig, error) { return repository, err } validID := repositoryLogID.MatchString(repository.LogID) - if !validID || repository.LogID == "." || repository.LogID == ".." { + if repository.LogID != "" && (!validID || repository.LogID == "." || repository.LogID == ".." || + strings.HasPrefix(repository.LogID, "auto-")) { return repository, errors.New("log_id must satisfy the WAL log identifier contract") } - if repository.Format != config.SHA1 && repository.Format != config.SHA256 { + if repository.Format != "" && repository.Format != config.SHA1 && repository.Format != config.SHA256 { return repository, config.ErrInvalidObjectFormat } if repository.DefaultBranch != "" { - if err := plumbing.ValidateBranchName(repository.DefaultBranch); err != nil { + err := plumbing.ValidateBranchName(repository.DefaultBranch) + if err == nil { + err = validateRefName(dotgit.New(memfs.New()), plumbing.NewBranchReferenceName(repository.DefaultBranch)) + } + if err != nil { return repository, fmt.Errorf("default_branch: %w", err) } } @@ -146,7 +168,7 @@ func uniqueRepositoryObject(encoded []byte) (map[string]json.RawMessage, error) } func validRepositoryName(name string) bool { - if !strings.HasSuffix(name, ".git") || strings.Contains(name, `\`) { + if len(name) == 0 || len(canonicalRepositoryName(name)) > 4096 || name == "*" || strings.Contains(name, `\`) { return false } path := &url.URL{Path: name} @@ -162,6 +184,17 @@ func validRepositoryName(name string) bool { return segments[len(segments)-1] != ".git" } +func canonicalRepositoryName(name string) string { + if !strings.HasSuffix(name, ".git") { + return name + ".git" + } + return name +} + +func automaticRepositoryID(name string) string { + return fmt.Sprintf("auto-%x", sha256.Sum256([]byte(name))) +} + type repositoryRoute struct { Name string Repository repositoryConfig @@ -170,7 +203,7 @@ type repositoryRoute struct { Action gitAction } -// Resolve exact provisioned names and endpoint suffixes without cleaning or +// Resolve exact permitted names and endpoint suffixes without cleaning or // decoding path aliases. The caller must authorize Action before opening a WAL. // A method error preserves Method so the caller can send an Allow header. func resolveRepository(repositories map[string]repositoryConfig, r *http.Request) (repositoryRoute, error) { @@ -183,7 +216,7 @@ func resolveRepository(repositories map[string]repositoryConfig, r *http.Request } for _, service := range []string{ "info/refs", transport.UploadPackService, transport.ReceivePackService, - "maintenance", "collect", "prune-invalid-refs", "recover-retentions-after-drain", + "maintenance", "collect", "recover-retentions-after-drain", } { suffix := "/" + service if strings.HasSuffix(r.URL.Path, suffix) { @@ -192,8 +225,16 @@ func resolveRepository(repositories map[string]repositoryConfig, r *http.Request break } } + if !validRepositoryName(route.Name) { + return repositoryRoute{}, errRepositoryNotFound + } + route.Name = canonicalRepositoryName(route.Name) repository, ok := repositories[route.Name] - if !ok || !validRepositoryName(route.Name) { + if !ok { + repository, ok = repositories["*"] + repository.LogID = automaticRepositoryID(route.Name) + } + if !ok { return repositoryRoute{}, errRepositoryNotFound } route.Repository = repository diff --git a/examples/git/repositories_test.go b/examples/git/repositories_test.go index ca1279c..6bc95b4 100644 --- a/examples/git/repositories_test.go +++ b/examples/git/repositories_test.go @@ -78,8 +78,6 @@ func TestLoadRepositoriesRejectsInvalidConfiguration(t *testing.T) { "a.git":{"log_id":"same","format":"sha1"}, "b.git":{"log_id":"same","format":"sha256"} }`}, - {name: "missing identity", text: `{"r.git":{"format":"sha1"}}`}, - {name: "missing format", text: `{"r.git":{"log_id":"one"}}`}, {name: "invalid format", text: `{"r.git":{"log_id":"one","format":"SHA1"}}`}, {name: "null format", text: `{"r.git":{"log_id":"one","format":null}}`}, {name: "path identity", text: `{"r.git":{"log_id":"a/b","format":"sha1"}}`}, @@ -88,6 +86,7 @@ func TestLoadRepositoriesRejectsInvalidConfiguration(t *testing.T) { {name: "invalid branch", text: `{"r.git":{"log_id":"one","format":"sha1","default_branch":"a..b"}}`}, {name: "HEAD branch", text: `{"r.git":{"log_id":"one","format":"sha1","default_branch":"HEAD"}}`}, {name: "hyphen branch", text: `{"r.git":{"log_id":"one","format":"sha1","default_branch":"-bad"}}`}, + {name: "unsafe branch", text: `{"r.git":{"log_id":"one","format":"sha1","default_branch":"\u200c./review-probe"}}`}, {name: "null branch", text: `{"r.git":{"log_id":"one","format":"sha1","default_branch":null}}`}, {name: "string groups", text: `{"r.git":{"log_id":"one","format":"sha1","read_groups":"everyone"}}`}, {name: "null groups", text: `{"r.git":{"log_id":"one","format":"sha1","read_groups":null}}`}, @@ -109,7 +108,7 @@ func TestLoadRepositoriesRejectsInvalidConfiguration(t *testing.T) { func TestLoadRepositoriesRejectsNoncanonicalNames(t *testing.T) { t.Parallel() for _, name := range []string{ - "", "r", ".git", "team/.git", "/r.git", "r.git/", "team//r.git", "./r.git", + "", ".git", "team/.git", "/r.git", "r.git/", "team//r.git", "./r.git", "team/./r.git", "team/../r.git", "../r.git", "r%2egit", `team\r.git`, "r?.git", "r#.git", "r space.git", "résumé.git", } { @@ -185,7 +184,6 @@ func TestResolveRepositorySelectsServiceAndAction(t *testing.T) { {name: "write RPC", method: http.MethodPost, path: "git-receive-pack", service: transport.ReceivePackService, action: gitWrite}, {name: "logical maintenance", method: http.MethodPost, path: "maintenance", service: "maintenance", action: gitAdmin}, - {name: "legacy ref cleanup", method: http.MethodPost, path: "prune-invalid-refs", service: "prune-invalid-refs", action: gitAdmin}, {name: "physical collection", method: http.MethodPost, path: "collect", service: "collect", action: gitAdmin}, {name: "retention recovery", method: http.MethodPost, path: "recover-retentions-after-drain", service: "recover-retentions-after-drain", action: gitAdmin}, @@ -212,7 +210,7 @@ func TestResolveRepositoryRejectsAliasesAndUnknownRoutes(t *testing.T) { repositories := repositoriesForTest(t) for _, path := range []string{ "/missing.git/git-upload-pack", "/sha1.git/git-upload-pack", "/team/alpha.git", - "/team/alpha.git/unknown", "/team/alpha.git/git-upload-pack/", "/team/alpha.git//git-upload-pack", + "/team/alpha.git/unknown", "/team/alpha.git/create", "/team/alpha.git/git-upload-pack/", "/team/alpha.git//git-upload-pack", "//team/alpha.git/git-upload-pack", "/team/./alpha.git/git-upload-pack", "/team/other/../alpha.git/git-upload-pack", "/team%2falpha.git/git-upload-pack", "/team/%61lpha.git/git-upload-pack", "/team/alpha%2egit/git-upload-pack", @@ -260,7 +258,6 @@ func TestRepositoryRoutePolicyIsIndependentPerRepositoryAndAction(t *testing.T) {name: "writer read denied", group: "writers", path: "team/alpha.git/git-upload-pack"}, {name: "writer admin denied", group: "writers", path: "team/alpha.git/maintenance"}, {name: "admin maintenance", group: "operators", path: "team/alpha.git/maintenance", want: true}, - {name: "admin ref cleanup", group: "operators", path: "team/alpha.git/prune-invalid-refs", want: true}, {name: "admin collection", group: "operators", path: "team/alpha.git/collect", want: true}, {name: "admin recovery", group: "operators", path: "team/alpha.git/recover-retentions-after-drain", want: true}, {name: "admin read denied", group: "operators", path: "team/alpha.git/git-upload-pack"}, @@ -285,3 +282,57 @@ func TestRepositoryRoutePolicyIsIndependentPerRepositoryAndAction(t *testing.T) }) } } + +func TestAutomaticRepositoryPolicyAndIdentity(t *testing.T) { + if !validRepositoryName(strings.Repeat("a", 4092)) || validRepositoryName(strings.Repeat("a", 4093)) { + t.Fatal("name limit must include the optional .git suffix") + } + repositories, err := repositoriesFromText(`{"*":{"write_groups":["writers"]},"team/private":{}}`) + if err != nil { + t.Fatal(err) + } + var first repositoryRoute + for _, name := range []string{"team/project", "team/project.git"} { + route, err := resolveRepository(repositories, httptest.NewRequest(http.MethodPost, "/"+name+"/git-receive-pack", nil)) + if err != nil { + t.Fatal(err) + } + if route.Name != "team/project.git" || route.Action != gitWrite || route.Repository.Format != "" { + t.Fatalf("unexpected automatic route: %+v", route) + } + if first.Name != "" && first.Repository.LogID != route.Repository.LogID { + t.Fatal("URL aliases select different logs") + } + first = route + } + principal := gitPrincipal{subject: "writer", groups: []string{"writers"}} + if !principal.Allows(first.Repository.repositoryAccess, gitWrite) || principal.Allows(first.Repository.repositoryAccess, gitRead) { + t.Fatal("automatic policy lost independent permissions") + } + private, err := resolveRepository(repositories, httptest.NewRequest(http.MethodPost, "/team/private.git/git-receive-pack", nil)) + if err != nil { + t.Fatal(err) + } + if principal.Allows(private.Repository.repositoryAccess, gitWrite) || private.Repository.LogID == first.Repository.LogID { + t.Fatal("exact denial or repository isolation lost") + } + for _, name := range []string{"*", "team/../project", "team//project", "%70roject"} { + if _, err := resolveRepository(repositories, httptest.NewRequest(http.MethodPost, "/"+name+"/git-receive-pack", nil)); err == nil { + t.Fatalf("wildcard accepted invalid name %q", name) + } + } + // Adding a permission override must preserve the automatically created log. + override, err := repositoriesFromText(`{"team/project":{"read_groups":["readers"]}}`) + if err != nil || override["team/project.git"].LogID != first.Repository.LogID { + t.Fatal("permission override moved storage") + } + for _, text := range []string{ + `{"*":{"log_id":"shared"}}`, + `{"project":{},"project.git":{}}`, + `{"project":{"log_id":"auto-forbidden"}}`, + } { + if _, err := repositoriesFromText(text); err == nil { + t.Fatalf("accepted ambiguous policy: %s", text) + } + } +} diff --git a/examples/git/spin.toml b/examples/git/spin.toml index 49a978c..ce453ec 100644 --- a/examples/git/spin.toml +++ b/examples/git/spin.toml @@ -15,7 +15,7 @@ wal_secret_key = { default = "", secret = true } wal_session_token = { default = "", secret = true } wal_max_collection_objects = { default = "100000" } wal_recover_retentions_after_drain = { default = "false" } -git_repositories = { default = '{"sha1.git":{"log_id":"repo-sha1","format":"sha1"},"sha256.git":{"log_id":"repo-sha256","format":"sha256"}}' } +git_repositories = { default = '{"*":{}}' } git_auth_mode = { default = "password" } git_cognito_issuer = { default = "" } git_cognito_client_id = { default = "" } diff --git a/examples/git/store.go b/examples/git/store.go index cfedc56..c68452a 100644 --- a/examples/git/store.go +++ b/examples/git/store.go @@ -9,9 +9,9 @@ import ( "io" "maps" "slices" - "strings" "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/format/config" "github.com/go-git/go-git/v6/plumbing/format/objfile" "github.com/go-git/go-git/v6/plumbing/storer" "github.com/go-git/go-git/v6/storage" @@ -31,6 +31,7 @@ type store struct { writers []*byteWriter storage.Storer failure error + progress *receiveProgress tailEntries uint64 session *wal.Session recovery *wal.Recovery @@ -46,12 +47,7 @@ func openStore(ctx context.Context, session *wal.Session, repository repositoryC return nil, err } format := repository.Format - mem := memory.NewStorage(memory.WithObjectFormat(format)) - // Memory storage returns its owned configuration; no save is needed. - cfg, _ := mem.Config() - // Reuse stored deltas without comparing object contents to generate new ones. - cfg.Pack.Window = 1 - s := &store{ctx: ctx, limits: limits, Storer: mem, session: session, buckets: map[string]*wal.Object{}, loaded: map[*wal.Object]radixNode[indexed, *wal.Object]{}, pending: map[string]indexed{}} + s := &store{ctx: ctx, limits: limits, session: session, buckets: map[string]*wal.Object{}, loaded: map[*wal.Object]radixNode[indexed, *wal.Object]{}, pending: map[string]indexed{}} defer func() { if result == nil { s.Close() @@ -78,30 +74,29 @@ func openStore(ctx context.Context, session *wal.Session, repository repositoryC for i, key := range s.meta.Buckets { s.buckets[key] = root.Objects[i] } - for name, id := range s.meta.Refs { - if e := s.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(name), plumbing.NewHash(id))); e != nil { - return nil, e - } + } + if s.meta.Format == "" { + s.meta.Format = config.SHA1 + } + s.Storer = memory.NewStorage(memory.WithObjectFormat(s.meta.Format)) + cfg, _ := s.Storer.Config() + // Reuse stored deltas without comparing object contents to generate new ones. + cfg.Pack.Window = 1 + for name, id := range s.meta.Refs { + if e := s.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(name), plumbing.NewHash(id))); e != nil { + return nil, e } } // An empty repository has no unchecked objects. Existing roots must certify validation. s.meta.Validated = true - head := s.meta.Head - if head == "" { - branch := "main" - if configured := repository.DefaultBranch; s.stateRoot == nil && configured != "" { - branch = configured + if s.stateRoot == nil { + branch := repository.DefaultBranch + if branch == "" { + branch = "main" } - head = "refs/heads/" + branch - } - if !strings.HasPrefix(head, "refs/heads/") { - return nil, fmt.Errorf("invalid default branch") - } - if e := plumbing.ReferenceName(head).Validate(); e != nil { - return nil, e + s.meta.Head = "refs/heads/" + branch } - s.meta.Head = head - if e := s.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName(head))); e != nil { + if e := s.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName(s.meta.Head))); e != nil { return nil, e } return s, nil @@ -166,21 +161,8 @@ func (s *store) EncodedObjectSize(id plumbing.Hash) (int64, error) { v, e := s.lookup(id) return v.Size, e } -func (s *store) IterEncodedObjects(kind plumbing.ObjectType) (storer.EncodedObjectIter, error) { - items := map[string]indexed{} - for prefix, root := range s.buckets { - if e := walkRadix(prefix, root, s.loadBucket, func(id string, item indexed) { items[id] = item }); e != nil { - return nil, e - } - } - maps.Copy(items, s.pending) - result := make([]plumbing.EncodedObject, 0, len(items)) - for _, item := range items { - if kind == plumbing.AnyObject || item.Kind == kind { - result = append(result, &storedObject{s, item}) - } - } - return storer.NewEncodedObjectSliceIter(result), nil +func (*store) IterEncodedObjects(plumbing.ObjectType) (storer.EncodedObjectIter, error) { + return nil, errors.ErrUnsupported } func (s *store) RawObjectWriter(kind plumbing.ObjectType, size int64) (io.WriteCloser, error) { if err := s.ctx.Err(); err != nil { @@ -198,23 +180,8 @@ func (s *store) RawObjectWriter(kind plumbing.ObjectType, size int64) (io.WriteC } return &objectWriter{s: s, kind: kind, size: size, codec: codec, sink: sink}, nil } -func (s *store) SetEncodedObject(o plumbing.EncodedObject) (plumbing.Hash, error) { - r, e := o.Reader() - if e != nil { - return plumbing.ZeroHash, e - } - defer r.Close() - w, e := s.RawObjectWriter(o.Type(), o.Size()) - if e != nil { - return plumbing.ZeroHash, e - } - if _, e = io.Copy(w, r); e != nil { - return plumbing.ZeroHash, e - } - if e = w.Close(); e != nil { - return plumbing.ZeroHash, e - } - return w.(*objectWriter).codec.Hash(), nil +func (*store) SetEncodedObject(plumbing.EncodedObject) (plumbing.Hash, error) { + return plumbing.ZeroHash, errors.ErrUnsupported } type objectWriter struct { diff --git a/examples/git/tests/access_test.go b/examples/git/tests/access_test.go index 617f145..2c08dbc 100644 --- a/examples/git/tests/access_test.go +++ b/examples/git/tests/access_test.go @@ -30,7 +30,6 @@ func TestAccess(t *testing.T) { {"POST", "/sha1.git/info/refs?service=git-upload-pack", password, 405}, {"GET", "/sha1.git/git-upload-pack", password, 405}, {"GET", "/sha1.git/maintenance", password, 405}, - {"GET", "/sha1.git/prune-invalid-refs", password, 405}, {"GET", "/sha1.git/recover-retentions-after-drain", password, 405}, {"POST", "/sha1.git/recover-retentions-after-drain", password, 403}, {"GET", "/sha1.git/info/refs?service=maintenance", password, 404}, @@ -40,7 +39,6 @@ func TestAccess(t *testing.T) { } if os.Getenv("GIT_PROBE_READ_ONLY") == "true" { cases = append(cases, request{"POST", "/sha1.git/maintenance", password, 403}) - cases = append(cases, request{"POST", "/sha1.git/prune-invalid-refs", password, 403}) } if password != "" { cases = append(cases, request{"GET", "/sha1.git/info/refs?service=git-upload-pack", "wrong", 401}) @@ -60,7 +58,10 @@ func TestAccess(t *testing.T) { if err != nil { t.Fatal(err) } - if response.StatusCode != c.status { + // Fresh storage has no catalog until the first push. The Git workflow + // test verifies successful reads after publication. + absent := os.Getenv("GIT_PROBE_CREATE") == "1" && c.status == http.StatusOK && strings.HasSuffix(c.path, "service=git-upload-pack") && response.StatusCode == http.StatusNotFound + if response.StatusCode != c.status && !absent { t.Errorf("%s %s: got %d, want %d", c.method, c.path, response.StatusCode, c.status) } if bootID != "" && response.Header.Get("X-Git-Boot-ID") != bootID { @@ -78,6 +79,9 @@ func TestPersistedHead(t *testing.T) { if endpoint == "" || branch == "" || os.Getenv("GIT_PROBE_PERSISTED_HEAD") != "true" { t.Skip("set GIT_PROBE_URL and GIT_PROBE_BRANCH for an initialized repository") } + if os.Getenv("GIT_PROBE_CREATE") == "1" { + endpoint = strings.TrimRight(endpoint, "/") + "/workflow" + } for _, format := range []string{"sha1", "sha256"} { refs := string(git(t, nil, "ls-remote", "--symref", strings.TrimRight(endpoint, "/")+"/"+format+".git", "HEAD")) if !strings.Contains(refs, "ref: refs/heads/"+branch+"\tHEAD") { diff --git a/examples/git/tests/creation_test.go b/examples/git/tests/creation_test.go new file mode 100644 index 0000000..f3f8cb2 --- /dev/null +++ b/examples/git/tests/creation_test.go @@ -0,0 +1,146 @@ +package tests + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRepositoryCreation(t *testing.T) { + endpoint := os.Getenv("GIT_PROBE_URL") + if endpoint == "" || os.Getenv("GIT_PROBE_CREATE") != "1" { + t.Skip("set GIT_PROBE_URL and GIT_PROBE_CREATE=1 for a local service with wildcard policy") + } + request := func(method, url, password, body string, want int) { + t.Helper() + r, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + r.SetBasicAuth("git", password) + r.Header.Set("Content-Type", "application/x-git-receive-pack-request") + response, err := http.DefaultClient.Do(r) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != want { + t.Fatalf("%s: HTTP %d, want %d: %s (%v)", url, response.StatusCode, want, data, err) + } + } + for _, format := range []string{"sha1", "sha256"} { + t.Run(format, func(t *testing.T) { + name := fmt.Sprintf("created/%s-%d", format, time.Now().UnixNano()) + url := strings.TrimRight(endpoint, "/") + "/" + name + password := os.Getenv("GIT_PROBE_PASSWORD") + read := url + "/info/refs?service=git-upload-pack" + request(http.MethodGet, url+"/info/refs?service=git-receive-pack", "wrong-password", "", 401) + request(http.MethodGet, read, password, "", 404) + request(http.MethodGet, url+"/info/refs?service=git-receive-pack", password, "", 200) + request(http.MethodPost, url+"/git-receive-pack", password, "0000", 200) + request(http.MethodGet, read, password, "", 404) + request(http.MethodPost, url+"/git-receive-pack", password, "invalid", 400) + width := 40 + if format == "sha256" { + width = 64 + } + command := packet(strings.Repeat("0", width) + " " + strings.Repeat("1", width) + " refs/heads/release\x00report-status object-format=" + format + "\n") + request(http.MethodPost, url+"/git-receive-pack", password, string(command)+"0000broken pack", 200) + request(http.MethodGet, read, password, "", 404) + root := t.TempDir() + git(t, nil, "init", "--object-format="+format, "-b", "release", root) + write(t, filepath.Join(root, "README.md"), []byte(name)) + git(t, nil, "-C", root, "add", ".") + git(t, nil, "-C", root, "commit", "-m", "created by first push") + git(t, nil, "-C", root, "push", url, "HEAD:refs/heads/release") + clone := filepath.Join(t.TempDir(), "clone") + git(t, nil, "-c", "protocol.version=2", "clone", url+".git", clone) + git(t, nil, "-C", clone, "fsck", "--full") + if got := git(t, nil, "-C", clone, "symbolic-ref", "--short", "HEAD"); strings.TrimSpace(string(got)) != "release" { + t.Fatal("first branch was not retained as default") + } + if got := git(t, nil, "-C", clone, "show", "HEAD:README.md"); !bytes.Equal(got, []byte(name)) { + t.Fatal("created repository data differs") + } + }) + } + t.Run("concurrent first pushes", func(t *testing.T) { + url := fmt.Sprintf("%s/created/race-%d", strings.TrimRight(endpoint, "/"), time.Now().UnixNano()) + type candidate struct { + format, tip string + body []byte + } + var pushes []candidate + for _, format := range []string{"sha1", "sha256"} { + root := t.TempDir() + git(t, nil, "init", "--object-format="+format, "-b", "main", root) + write(t, filepath.Join(root, "README.md"), []byte(format)) + git(t, nil, "-C", root, "add", ".") + git(t, nil, "-C", root, "commit", "-m", "first push") + tip := strings.TrimSpace(string(git(t, nil, "-C", root, "rev-parse", "HEAD"))) + body := append(packet(strings.Repeat("0", len(tip))+" "+tip+" refs/heads/main\x00report-status object-format="+format+"\n"), []byte("0000")...) + body = append(body, git(t, []byte(tip+"\n"), "-C", root, "pack-objects", "--revs", "--stdout")...) + pushes = append(pushes, candidate{format, tip, body}) + } + type result struct { + candidate + status int + body []byte + err error + } + results := make(chan result, 2) + start := make(chan struct{}) + for _, push := range pushes { + go func() { + r := result{candidate: push} + request, err := http.NewRequest(http.MethodPost, url+"/git-receive-pack", bytes.NewReader(push.body)) + if err == nil { + request.SetBasicAuth("git", os.Getenv("GIT_PROBE_PASSWORD")) + request.Header.Set("Content-Type", "application/x-git-receive-pack-request") + <-start + var response *http.Response + response, err = http.DefaultClient.Do(request) + if err == nil { + r.status = response.StatusCode + r.body, err = io.ReadAll(response.Body) + response.Body.Close() + } + } + r.err = err + results <- r + }() + } + close(start) + winner := "" + for range 2 { + r := <-results + if r.err != nil { + t.Fatal(r.err) + } + if r.status == 200 && bytes.Contains(r.body, []byte("ok refs/heads/main")) { + if winner != "" { + t.Fatal("two incompatible first pushes committed") + } + winner = r.tip + } else if r.status != 400 && !bytes.Contains(r.body, []byte("ng refs/heads/main")) { + t.Fatalf("unexpected losing push: status=%d reply=%s", r.status, r.body) + } + } + if winner == "" { + t.Fatal("no first push committed") + } + clone := filepath.Join(t.TempDir(), "clone") + git(t, nil, "clone", url, clone) + git(t, nil, "-C", clone, "fsck", "--full") + if tip := strings.TrimSpace(string(git(t, nil, "-C", clone, "rev-parse", "HEAD"))); tip != winner { + t.Fatal("acknowledged first push was overwritten") + } + }) +} diff --git a/examples/git/tests/provider_test.go b/examples/git/tests/provider_test.go index fbb1008..a11a164 100644 --- a/examples/git/tests/provider_test.go +++ b/examples/git/tests/provider_test.go @@ -27,6 +27,9 @@ func TestWALGit(t *testing.T) { if endpoint == "" { t.Skip("set GIT_PROBE_URL to an isolated local Spin/MinIO instance") } + if os.Getenv("GIT_PROBE_CREATE") == "1" { + endpoint = strings.TrimRight(endpoint, "/") + "/workflow" + } branch := os.Getenv("GIT_PROBE_BRANCH") if branch == "" { branch = "main" diff --git a/examples/git/tests/repositories_provider_test.go b/examples/git/tests/repositories_provider_test.go index d5a16bc..1dbc391 100644 --- a/examples/git/tests/repositories_provider_test.go +++ b/examples/git/tests/repositories_provider_test.go @@ -12,7 +12,7 @@ import ( ) // Configure these three names with distinct log IDs on a fresh storage prefix, -// password/token authentication, and default branch main. The test uses the +// password/token authentication. The first push selects default branch main. The test uses the // supplied server as-is; it does not rebuild or relaunch Spin. func TestRepositoryIsolation(t *testing.T) { if os.Getenv("GIT_MULTI_REPOSITORIES") != "1" { @@ -38,12 +38,12 @@ func TestRepositoryIsolation(t *testing.T) { repositoryStatus(t, write, "invalid-token", http.StatusUnauthorized) repositoryStatus(t, read, os.Getenv("GIT_PROBE_PASSWORD"), http.StatusNotFound) repositoryStatus(t, write, os.Getenv("GIT_PROBE_PASSWORD"), http.StatusOK) - repositoryStatus(t, read, os.Getenv("GIT_PROBE_PASSWORD"), http.StatusOK) + } for _, path := range []string{ "unknown/project.git", "alpha/project.git/extra", "alpha//project.git", } { - repositoryStatus(t, endpoint+"/"+path+"/info/refs?service=git-receive-pack", + repositoryStatus(t, endpoint+"/"+path+"/info/refs?service=git-upload-pack", os.Getenv("GIT_PROBE_PASSWORD"), http.StatusNotFound) } for i := range repositories { diff --git a/examples/git/validate.go b/examples/git/validate.go index 05f881c..19cd3c2 100644 --- a/examples/git/validate.go +++ b/examples/git/validate.go @@ -18,6 +18,9 @@ func validate(st *store, cmds []*packp.Command) (map[string]string, error) { refNames := dotgit.New(memfs.New()) for _, cmd := range cmds { name := string(cmd.Name) + if cmd.Old.HexSize() != st.meta.Format.HexSize() || cmd.New.HexSize() != st.meta.Format.HexSize() { + return nil, fmt.Errorf("invalid object format") + } if seen[name] || (cmd.Old.IsZero() && cmd.New.IsZero()) { return nil, fmt.Errorf("invalid ref update") } @@ -57,7 +60,7 @@ func validate(st *store, cmds []*packp.Command) (map[string]string, error) { } refs[name] = cmd.New.String() } - if err := validateRefs(st.meta.Format, refs); err != nil { + if err := validateRefs(refNames, st.meta.Format, refs); err != nil { return nil, err } ids := make([]plumbing.Hash, 0, len(st.pending)) diff --git a/examples/git/validate_native_test.go b/examples/git/validate_native_test.go index b211b67..1b5693a 100644 --- a/examples/git/validate_native_test.go +++ b/examples/git/validate_native_test.go @@ -36,3 +36,22 @@ func TestValidateRejectsUnsafeRefBeforePublication(t *testing.T) { }) } } + +func TestValidateRejectsMixedCommandFormats(t *testing.T) { + for _, format := range []config.ObjectFormat{config.SHA1, config.SHA256} { + st := &store{Storage: memory.NewStorage(memory.WithObjectFormat(format)), meta: rootMeta{Format: format, Refs: map[string]string{}}} + other := config.SHA1 + if format == other { + other = config.SHA256 + } + for _, ids := range [][2]config.ObjectFormat{{other, format}, {format, other}} { + command := &packp.Command{Name: "refs/heads/main", + Old: plumbing.NewHash(strings.Repeat("1", ids[0].HexSize())), + New: plumbing.NewHash(strings.Repeat("0", ids[1].HexSize())), + } + if refs, err := validate(st, []*packp.Command{command}); err == nil || refs != nil || !strings.Contains(err.Error(), "object format") { + t.Fatalf("mixed-format deletion accepted: refs=%v error=%v", refs, err) + } + } + } +} diff --git a/examples/git/validate_refname.go b/examples/git/validate_refname.go index ef0e15d..a1d7f39 100644 --- a/examples/git/validate_refname.go +++ b/examples/git/validate_refname.go @@ -3,10 +3,7 @@ package main import ( "errors" "fmt" - "maps" - "slices" - "github.com/go-git/go-billy/v6/memfs" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/storage/filesystem/dotgit" ) @@ -28,29 +25,3 @@ func validateRefName(refNames *dotgit.DotGit, name plumbing.ReferenceName) error } return nil } - -// Remove only refs admitted by the old catalog rules but refused by receive-pack. -// An invalid default HEAD requires an explicit, already-existing replacement. -func withoutInvalidRefs(meta rootMeta, replacement string) (rootMeta, []string, error) { - refNames := dotgit.New(memfs.New()) - clean := meta - clean.Refs = maps.Clone(meta.Refs) - removed := []string{} - headInvalid := validateRefName(refNames, plumbing.ReferenceName(meta.Head)) != nil - for name := range meta.Refs { - if validateRefName(refNames, plumbing.ReferenceName(name)) != nil { - delete(clean.Refs, name) - removed = append(removed, name) - } - } - slices.Sort(removed) - if headInvalid { - if !plumbing.ReferenceName(replacement).IsBranch() || clean.Refs[replacement] == "" { - return rootMeta{}, nil, fmt.Errorf("invalid default HEAD requires an existing valid replacement branch") - } - clean.Head = replacement - } else if replacement != "" { - return rootMeta{}, nil, fmt.Errorf("replacement HEAD is only allowed when the stored HEAD is invalid") - } - return clean, removed, nil -} diff --git a/examples/git/validate_refname_test.go b/examples/git/validate_refname_test.go index fb9d6ee..841d346 100644 --- a/examples/git/validate_refname_test.go +++ b/examples/git/validate_refname_test.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "encoding/json" "errors" "io" "os/exec" @@ -71,61 +70,6 @@ func TestReceiveRefNamesBeforePublication(t *testing.T) { } } -func TestPruneLegacyRefNames(t *testing.T) { - legacy := "refs/heads/\u200c./review-probe" - for _, format := range []config.ObjectFormat{config.SHA1, config.SHA256} { - t.Run(format.String(), func(t *testing.T) { - id := strings.Repeat("1", format.HexSize()) - original := rootMeta{Validated: true, Format: format, Head: "refs/heads/main", Refs: map[string]string{ - legacy: id, "refs/heads/main": id, "refs/tags/release": id, - }} - encoded, err := json.Marshal(original) - if err != nil { - t.Fatal(err) - } - loaded, err := decodeRoot(encoded, format, 0) - if err != nil { - t.Fatalf("old catalog must remain readable: %v", err) - } - clean, removed, err := withoutInvalidRefs(loaded, "") - if err != nil || len(removed) != 1 || removed[0] != legacy || len(clean.Refs) != 2 || len(loaded.Refs) != 3 { - t.Fatalf("cleanup removed wrong refs: clean=%v removed=%v", clean, removed) - } - if clean.Refs["refs/heads/main"] != id || clean.Refs["refs/tags/release"] != id { - t.Fatalf("valid refs changed: %v", clean) - } - clean, removed, err = withoutInvalidRefs(clean, "") - if err != nil || len(removed) != 0 || len(clean.Refs) != 2 { - t.Fatalf("repeated cleanup changed valid refs: %v %v", clean, removed) - } - loaded.Head = legacy - encoded, err = json.Marshal(loaded) - if err != nil { - t.Fatal(err) - } - loaded, err = decodeRoot(encoded, format, 0) - if err != nil { - t.Fatalf("old default HEAD must remain readable: %v", err) - } - if _, _, err := withoutInvalidRefs(loaded, ""); err == nil { - t.Fatal("invalid default HEAD was removed without replacement") - } - if _, _, err := withoutInvalidRefs(loaded, "refs/tags/release"); err == nil { - t.Fatal("replacement HEAD accepted a tag") - } - clean, removed, err = withoutInvalidRefs(loaded, "refs/heads/main") - if err != nil || clean.Head != "refs/heads/main" || len(removed) != 1 || len(loaded.Refs) != 3 { - t.Fatalf("explicit HEAD replacement failed: clean=%v removed=%v error=%v", clean, removed, err) - } - delete(loaded.Refs, legacy) - clean, removed, err = withoutInvalidRefs(loaded, "refs/heads/main") - if err != nil || clean.Head != "refs/heads/main" || len(removed) != 0 { - t.Fatalf("unborn invalid HEAD was not replaced: clean=%v removed=%v error=%v", clean, removed, err) - } - }) - } -} - func receiveRefForTest(t *testing.T, format config.ObjectFormat, command *packp.Command, pack []byte, hook func(*transport.PreReceiveInfo) error) (string, error) { t.Helper() var request, reply bytes.Buffer diff --git a/examples/git/viewer/.gitignore b/examples/git/viewer/.gitignore new file mode 100644 index 0000000..cc3b8ba --- /dev/null +++ b/examples/git/viewer/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/build/ +/dist/ diff --git a/examples/git/viewer/README.md b/examples/git/viewer/README.md new file mode 100644 index 0000000..5efd3e6 --- /dev/null +++ b/examples/git/viewer/README.md @@ -0,0 +1,79 @@ +# Repository explorer + +A small repository browser with a TypeScript Spin API and Preact UI. +It shows branches, directories, file previews and recent commits for authorized +repositories. The API and UI live in a separate component; the WAL is unchanged. + +The API checks access through ordinary Git discovery, then reads the repository's +existing WAL catalog directly. Reads follow only the selected history and paths; +opening a large file summary does not download the file. SHA-1 and SHA-256 both +work. Storage settings come from the same Spin variables as the Git example. + +## Run locally + +Install the [Git prerequisites](../README.md), [Bun](https://bun.com/), and Node.js. +From the repository root: + +```sh +cd examples/git/viewer +bun install --frozen-lockfile +bun start +``` + +This builds both components and starts disposable local MinIO and Spin. Open +`http://127.0.0.1:19100/`. Storage starts empty; push an existing repository: + +```sh +git push http://127.0.0.1:19100/team/project.git HEAD +``` + +The first successful push creates the repository using the local Git format and +first branch. SHA-1 is Git's default; SHA-256 repositories work too. When Git +prompts, use username `git` and password `local-git-password`. + +Enter `team/project` in the viewer and sign in with the same password to browse +its files and commits. The default `"*"` policy admits new names without editing +TOML or restarting. Browsing a missing repository creates nothing. Bare names +and their `.git` URLs select the same repository. Exact policies may override +permissions or pin a WAL identity, format or default branch. + +Credentials stay in page memory and clear on reload. Branch, commit and path selections +stay in the URL. Press Ctrl-C in the startup terminal to stop both services and +remove their disposable data. Each restart begins with empty storage. + +For an existing backend, use `bun run build`, then follow the +[Git example's configuration](../README.md#configuration) and add +`-f spin.viewer.toml` to its `spin up` command. + +Hosted proxies must forward `/`, `/_viewer/api`, `/_viewer/client.js` and +`/_viewer/style.css` to Spin. `GET /_viewer/api` takes `repo` and optional +`ref`, `commit` (full SHA-1 or SHA-256 hash) and `path` query parameters. Git traffic +goes directly to the Git component; the viewer has no repository-creation endpoint. + +## Build and checks + +Bun bundles the browser and API; `j2w` builds the WASI component, then `wac` links +it with the reusable WAL component. The build generates `viewer/dist/viewer.wasm` +and `spin.viewer.toml` from the existing manifest. Rebuild after changing +`spin.toml`. Normal Git builds do not require Bun. + +`bun run check` runs Biome and strict TypeScript checks; `bun test` checks catalog +reads, object validation and authorization. `bun run format` applies formatting +and safe lint fixes. CI checks, tests and builds with the frozen Bun lockfile. + +The compiler currently needs the small fix for +[ComponentizeJS #221](https://github.com/bytecodealliance/ComponentizeJS/issues/221), +which otherwise omits the classes for opaque WAL handles. Exact build provenance +is listed in [THIRD_PARTY.md](../../../THIRD_PARTY.md). + +## Scope + +Branch tips and commits in the authorized repository catalog are selectable. +Commit links retain branch context and load that commit's tree and eight first-parent +summaries; selecting an older entry continues history without scanning from the tip. +The UI shows 500 entries per directory and UTF-8 text previews up to 256 KiB. Binary and +larger files show a summary. Symlinks show their stored target; submodules show +their commit ID. Non-UTF-8 filenames appear as byte escapes without a browse link; valid sibling +files remain browsable. Names and file content render as text. Git metadata reads are +limited to 16 MiB per object and 64 MiB of catalog data per request. An expired +view retries once, preserving cumulative read accounting. diff --git a/examples/git/viewer/biome.json b/examples/git/viewer/biome.json new file mode 100644 index 0000000..cc7f22c --- /dev/null +++ b/examples/git/viewer/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "files": { + "includes": ["src/**", "build.ts", "*.json", "prepare.ts"] + }, + "formatter": { + "indentStyle": "space", + "lineWidth": 100 + }, + "linter": { + "rules": { + "preset": "recommended" + } + } +} diff --git a/examples/git/viewer/build.ts b/examples/git/viewer/build.ts new file mode 100644 index 0000000..d80144d --- /dev/null +++ b/examples/git/viewer/build.ts @@ -0,0 +1,35 @@ +const client = await Bun.build({ + entrypoints: ["src/client.tsx"], + target: "browser", + minify: true, +}); +if (!client.success) throw new AggregateError(client.logs, "Browser build failed"); +const component = await Bun.build({ + entrypoints: ["src/index.ts"], + target: "browser", + external: ["object-log:*", "fermyon:*"], + outdir: "build", + naming: "component.js", + define: { + CLIENT_SCRIPT: JSON.stringify(await client.outputs[0].text()), + STYLES: JSON.stringify(await Bun.file("src/style.css").text()), + PAGE: JSON.stringify(await Bun.file("src/page.html").text()), + }, +}); +if (!component.success) throw new AggregateError(component.logs, "Spin bundle failed"); +const manifest = await Bun.file("../spin.toml").text(); +const variables = [...manifest.matchAll(/^(wal_\w+|git_repositories) = "\{\{ (\w+) \}\}"$/gm)] + .map((match) => `${match[1]} = "{{ ${match[2]} }}"\n`) + .join(""); +const routes = ["/", "/_viewer/api", "/_viewer/client.js", "/_viewer/style.css"]; +await Bun.write( + "../spin.viewer.toml", + manifest + + routes + .map((route) => `\n[[trigger.http]]\nroute = "${route}"\ncomponent = "viewer"\n`) + .join("") + + '\n[component.viewer]\nsource = "viewer/dist/viewer.wasm"\nallowed_outbound_hosts = ["http://git.spin.internal", "{{ wal_endpoint }}", "http://169.254.169.254"]\n[component.viewer.variables]\n' + + variables, +); + +export {}; diff --git a/examples/git/viewer/bun.lock b/examples/git/viewer/bun.lock new file mode 100644 index 0000000..861596f --- /dev/null +++ b/examples/git/viewer/bun.lock @@ -0,0 +1,473 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "object-log-repo-viewer", + "dependencies": { + "@noble/hashes": "2.4.0", + "@spinframework/spin-variables": "1.0.1", + "@spinframework/wasi-http-proxy": "2.0.0", + "fflate": "0.8.3", + "preact": "10.29.8", + }, + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@bytecodealliance/preview2-shim": "0.26.0", + "@spinframework/build-tools": "2.1.1", + "@types/bun": "1.3.13", + "typescript": "7.0.2", + }, + }, + }, + "overrides": { + "@bytecodealliance/componentize-js": "https://github.com/carsonfarmer/ComponentizeJS/releases/download/opaque-resource-v0.23.0/bytecodealliance-componentize-js-0.23.0-opaque-resource.1.tgz", + }, + "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@andreiltd/componentize-qjs-binding-darwin-arm64": ["@andreiltd/componentize-qjs-binding-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CW+ukVP778x2NAHAC1FvXb5KVSXmUlUQxhQTFtF0mAqqOVnYrQgy4DDNUa48EGthwtLdgLT1+eE71qmQiyMEqg=="], + + "@andreiltd/componentize-qjs-binding-darwin-x64": ["@andreiltd/componentize-qjs-binding-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-37uz97pCEuXNfoVuZMBbI6xUirSndxJyMzPa4UCQb9nJdZhzRujmrU79yiFl+5nDYQxcCjthhBVEKU3OxJNAdg=="], + + "@andreiltd/componentize-qjs-binding-linux-arm64-gnu": ["@andreiltd/componentize-qjs-binding-linux-arm64-gnu@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-L5S0C6qIx3f+DnwN42HnDuMhV8NgfqaY+J1PaJAw468Ip/JN+h67tVAp4wUvs8xgTqj6UnGSBbSZVGYifK2XtA=="], + + "@andreiltd/componentize-qjs-binding-linux-arm64-musl": ["@andreiltd/componentize-qjs-binding-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBaq8/tvQ6HweqBLKWkE5tsPEMf7g3l5EGtka3Z7AFDUOtFzkAkG67nW1OYiYT6/0pjwDYw7Mvya45f79PG28g=="], + + "@andreiltd/componentize-qjs-binding-linux-x64-gnu": ["@andreiltd/componentize-qjs-binding-linux-x64-gnu@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Xec4O4sy/xHlhPPhf7GAV41D+AT7f9vxxjBpE7lUIElZNhX/kc+Ty6AQG50xbHsT5Mi4XQvSmtJJqhnB6xi8Vw=="], + + "@andreiltd/componentize-qjs-binding-linux-x64-musl": ["@andreiltd/componentize-qjs-binding-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-62+PgJ+tJOjgNvXzRuz3s76tPBaExCodeVhDIIXkGI/PNbulqTHiyYXRw4O3UX6/CO71Nux8onqnXxQkcXBlvg=="], + + "@andreiltd/componentize-qjs-binding-win32-arm64-msvc": ["@andreiltd/componentize-qjs-binding-win32-arm64-msvc@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-OZO/omsMQFpw3wJJs3XFN+iEu2ZQCWMK8BxkhmXVm1sddEHtgSTY2sT/zq69VZeXjC0zgN1HzlxHbNBq0OkcOQ=="], + + "@andreiltd/componentize-qjs-binding-win32-x64-msvc": ["@andreiltd/componentize-qjs-binding-win32-x64-msvc@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IlhlcNOEFky46ypfiD6l8MS45zosfhKWUclr10evQFw5Cpky+pji2RAGJRnBXdLQvH4Q3bZr6KYlIhxmtGRhkw=="], + + "@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], + + "@bytecodealliance/componentize-js": ["@bytecodealliance/componentize-js@https://github.com/carsonfarmer/ComponentizeJS/releases/download/opaque-resource-v0.23.0/bytecodealliance-componentize-js-0.23.0-opaque-resource.1.tgz", { "dependencies": { "@bytecodealliance/jco": "^1.15.1", "@bytecodealliance/weval": "^0.5.0", "@bytecodealliance/wizer": "^10.0.0", "es-module-lexer": "^1.6.0", "oxc-parser": "^0.76.0" }, "bin": { "componentize-js": "src/cli.js" } }, "sha512-yBBXoRshOde6eKRn4fbrUWNENUIsZvLpzAmd/ba4HPR/uxCwbDbrp5oZiWMe4TMvftIST/4hyz6Mj17d6FEffg=="], + + "@bytecodealliance/componentize-js-0-19-3": ["@bytecodealliance/componentize-js@0.19.3", "", { "dependencies": { "@bytecodealliance/jco": "^1.15.1", "@bytecodealliance/wizer": "^10.0.0", "es-module-lexer": "^1.6.0", "oxc-parser": "^0.76.0" }, "bin": { "componentize-js": "src/cli.js" } }, "sha512-ju7Y4WeF0B9uMkSPHJgmT6ouEfSwbe9M1uR/YOnYZjBpxJjH9qzxIkJg/kf8NycVDyFJ2/lscmJ1E1uPiDQVRQ=="], + + "@bytecodealliance/jco": ["@bytecodealliance/jco@1.35.0", "", { "dependencies": { "@bytecodealliance/componentize-js": "^0.22.0", "@bytecodealliance/componentize-js-0-19-3": "npm:@bytecodealliance/componentize-js@^0.19.3", "@bytecodealliance/jco-std": "^0.5.0", "@bytecodealliance/jco-transpile": "^0.15.0", "@bytecodealliance/preview2-shim": "^0.26.0", "binaryen": "^130.0.0", "commander": "^14", "componentize-qjs": "^0.4.5", "mkdirp": "^3", "regexpu-core": "6.4.0", "rolldown": "^1.2.4", "typescript": "7.0.2", "typescript-compiler-api": "npm:typescript@6.0.3", "unenv": "2.0.0-rc.24" }, "bin": { "jco": "dist/jco.js" } }, "sha512-O53cWIMm/M1SpwLxKRicvAMDnzWf4zWs6I6qFZIGbn3HUovDMCCe9blkGxxYuyocHjVf/cXgNmlMu/x7Tyi77A=="], + + "@bytecodealliance/jco-node-fs": ["@bytecodealliance/jco-node-fs@0.3.2", "", { "optionalDependencies": { "@bytecodealliance/jco-node-fs-darwin-arm64": "0.3.2", "@bytecodealliance/jco-node-fs-darwin-x64": "0.3.2", "@bytecodealliance/jco-node-fs-linux-arm64-gnu": "0.3.2", "@bytecodealliance/jco-node-fs-linux-arm64-musl": "0.3.2", "@bytecodealliance/jco-node-fs-linux-x64-gnu": "0.3.2", "@bytecodealliance/jco-node-fs-linux-x64-musl": "0.3.2", "@bytecodealliance/jco-node-fs-win32-arm64-msvc": "0.3.2", "@bytecodealliance/jco-node-fs-win32-x64-msvc": "0.3.2" } }, "sha512-yxvqQreNcOQdMsEKZomkSqThtkxrAz3f1Cmy8BsV0UHi9saa2/D1KDB14IAXPYwDq/maS6lCzS+MuMQx6QkUgA=="], + + "@bytecodealliance/jco-node-fs-darwin-arm64": ["@bytecodealliance/jco-node-fs-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WXQq1tRVUWhbK+ImH0G8Kni2AR0sdokLf/KyXRw5P6n4A9ZbgMHOuPfN/PEsYgw4eosuTAonutK0xILk/hA81A=="], + + "@bytecodealliance/jco-node-fs-darwin-x64": ["@bytecodealliance/jco-node-fs-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Fy/OjHnto7hNE5ksewVkmAMXG/uXyj+RAKFZWQFTzFkp73UyT5vuPjKq3nvx9KolUGWBG37BudEMb4ZAoD9TbQ=="], + + "@bytecodealliance/jco-node-fs-linux-arm64-gnu": ["@bytecodealliance/jco-node-fs-linux-arm64-gnu@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OafRtRu3tfOFQARfkia/LDEqSo2R3yAL1xPwzf4RcRW6JqkOY7qKWsUS5+LPVo/aFaSkMX7F7bdFGpFPmcoZJg=="], + + "@bytecodealliance/jco-node-fs-linux-arm64-musl": ["@bytecodealliance/jco-node-fs-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-JblDiwD7+Y+w0d/tjaNDX4QdGvh5lyuDc+S/jDeT7sYA1caA9yiwIjLa8PC/lm2xudqSxD+Cuy4AosXvl+qI0g=="], + + "@bytecodealliance/jco-node-fs-linux-x64-gnu": ["@bytecodealliance/jco-node-fs-linux-x64-gnu@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-6FzmHoLOsa6b4X9AuFLsvlpNF66Dt4sRRftZHaT1I1pYzCWrJkkpgNJhRXxf67ZPmOwBZJT4dan/g752Rx4xRw=="], + + "@bytecodealliance/jco-node-fs-linux-x64-musl": ["@bytecodealliance/jco-node-fs-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-SbAJG2PDV8YAHBESHU9sVSckOitJyvlezdF4CGP5/mOp01JnijVkLVXzGK62yVSfa5TpAybbSd1rk+v36NAW/Q=="], + + "@bytecodealliance/jco-node-fs-win32-arm64-msvc": ["@bytecodealliance/jco-node-fs-win32-arm64-msvc@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUWYdFysRQJGsIOCk0KAYN2smkNYiHG7rZ7m+0ZXqP+5wqWxKEjkDsORwXiBjdaWFLVww0anZ3SBxrju6P7hXQ=="], + + "@bytecodealliance/jco-node-fs-win32-x64-msvc": ["@bytecodealliance/jco-node-fs-win32-x64-msvc@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-RsPj2t8TvltsMWK7/Z7JcVC/IWPMi07/AJkNLiNTCuGTdT/sseqTThJJwhuz3sjFw7dJdxA+bSGT+23y/KT0fw=="], + + "@bytecodealliance/jco-std": ["@bytecodealliance/jco-std@0.5.0", "", { "dependencies": { "acorn": "8.17.0", "acorn-walk": "8.3.5", "minimatch": "10.2.6", "punycode": "2.3.1", "readable-stream": "4.7.0", "urlpattern-polyfill": "10.1.0", "whatwg-url": "14.2.0" }, "peerDependencies": { "@bytecodealliance/preview2-shim": "^0.25.0" } }, "sha512-TLklLNHUbCtXH9nRDDhZuk8rPYW0kd5R0ClvwpIXCBOyESHZGIGU1y7V47Uggz3i0h5WG3+srOg3aKfyZT4IIg=="], + + "@bytecodealliance/jco-transpile": ["@bytecodealliance/jco-transpile@0.15.0", "", { "dependencies": { "@bytecodealliance/preview2-shim": "^0.26.0", "@bytecodealliance/preview3-shim": "^0.7.0", "binaryen": "^130.0.0", "oxc-minify": "^0.136.0" } }, "sha512-MFftJ81B9TtHbvgCRZQod3jfgl62YvJszxsNdfx9IVNewxlX9q7U4Wd+w6gJzw+o9IjEGWoyg/BFpb/RnaJvlA=="], + + "@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.26.0", "", { "dependencies": { "@bytecodealliance/jco-node-fs": "0.3.2" } }, "sha512-oYekIJYroM1yJygziKpuMGmL2XDKZ4XspkdxfKCQqF/E0tVkCxyRRT+gFP0dFzhrSL8oh6obRCv84bBf1sYmAA=="], + + "@bytecodealliance/preview3-shim": ["@bytecodealliance/preview3-shim@0.7.0", "", { "dependencies": { "@bytecodealliance/jco-node-fs": "0.3.2", "@bytecodealliance/preview2-shim": "^0.26.0" } }, "sha512-5bBAE1nKSIT6ZyWoyLQwV1VjJciAdgLk7hbMeO3y5mLJhrkBL9egE48x5IfvU1ubMsQsKWFrP0jle7ow0b5I6w=="], + + "@bytecodealliance/weval": ["@bytecodealliance/weval@0.5.0", "", { "dependencies": { "@napi-rs/lzma": "^1.1.2", "fflate": "^0.8.3", "tar": "^7.5.19" } }, "sha512-lIR6+enn52gcdMQM8C3YaNoUlzm90TRkJrIgiVky/hED81ezDjhnq+6Z4w0eSFf9C6b2nmdKUFwbp6q1PhiuIQ=="], + + "@bytecodealliance/wizer": ["@bytecodealliance/wizer@10.0.0", "", { "optionalDependencies": { "@bytecodealliance/wizer-darwin-arm64": "10.0.0", "@bytecodealliance/wizer-darwin-x64": "10.0.0", "@bytecodealliance/wizer-linux-arm64": "10.0.0", "@bytecodealliance/wizer-linux-s390x": "10.0.0", "@bytecodealliance/wizer-linux-x64": "10.0.0", "@bytecodealliance/wizer-win32-x64": "10.0.0" }, "bin": { "wizer": "wizer.js" } }, "sha512-ziWmovyu1jQl9TsKlfC2bwuUZwxVPFHlX4fOqTzxhgS76jITIo45nzODEwPgU+jjmOr8F3YX2V2wAChC5NKujg=="], + + "@bytecodealliance/wizer-darwin-arm64": ["@bytecodealliance/wizer-darwin-arm64@10.0.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "wizer-darwin-arm64": "wizer" } }, "sha512-dhZTWel+xccGTKSJtI9A7oM4yyP20FWflsT+AoqkOqkCY7kCNrj4tmMtZ6GXZFRDkrPY5+EnOh62sfShEibAMA=="], + + "@bytecodealliance/wizer-darwin-x64": ["@bytecodealliance/wizer-darwin-x64@10.0.0", "", { "os": "darwin", "cpu": "x64", "bin": { "wizer-darwin-x64": "wizer" } }, "sha512-r/LUIZw6Q3Hf4htd46mD+EBxfwjBkxVIrTM1r+B2pTCddoBYQnKVdVsI4UFyy7NoBxzEg8F8BwmTNoSLmFRjpw=="], + + "@bytecodealliance/wizer-linux-arm64": ["@bytecodealliance/wizer-linux-arm64@10.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "wizer-linux-arm64": "wizer" } }, "sha512-pGSfFWXzeTqHm6z1PtVaEn+7Fm3QGC8YnHrzBV4sQDVS3N1NwmuHZAc8kslmlFPNdu61ycEvdOsSgCny8JPQvg=="], + + "@bytecodealliance/wizer-linux-s390x": ["@bytecodealliance/wizer-linux-s390x@10.0.0", "", { "os": "linux", "cpu": "s390x", "bin": { "wizer-linux-s390x": "wizer" } }, "sha512-O8vHxRTAdb1lUnVXMIMTcp/9q4pq1D4iIKigJCipg2JN15taV9uFAWh0fO88wylXwuSlO7dOE1AwQl54fMKXQg=="], + + "@bytecodealliance/wizer-linux-x64": ["@bytecodealliance/wizer-linux-x64@10.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "wizer-linux-x64": "wizer" } }, "sha512-fJtM1sy43FBMnp+xpapFX6U1YdTBKA/1T4CYfG/qeE8jn0SXk2EuiYoY/EnC2uyNy9hjTrvfdYO5n4MXW0EIdQ=="], + + "@bytecodealliance/wizer-win32-x64": ["@bytecodealliance/wizer-win32-x64@10.0.0", "", { "os": "win32", "cpu": "x64", "bin": { "wizer-win32-x64": "wizer" } }, "sha512-55BPLfGT7iT7gH5M69NpTM16QknJZ7OxJ0z73VOEoeGA9CT8QPKMRzFKsPIvLs+W8G28fdudFA94nElrdkp3Kg=="], + + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/lzma": ["@napi-rs/lzma@1.5.1", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.5.1", "@napi-rs/lzma-android-arm64": "1.5.1", "@napi-rs/lzma-darwin-arm64": "1.5.1", "@napi-rs/lzma-darwin-x64": "1.5.1", "@napi-rs/lzma-freebsd-x64": "1.5.1", "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", "@napi-rs/lzma-linux-arm64-musl": "1.5.1", "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@napi-rs/lzma-linux-x64-musl": "1.5.1", "@napi-rs/lzma-wasm32-wasi": "1.5.1", "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", "@napi-rs/lzma-win32-x64-msvc": "1.5.1" } }, "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g=="], + + "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.5.1", "", { "os": "android", "cpu": "arm" }, "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ=="], + + "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA=="], + + "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g=="], + + "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg=="], + + "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA=="], + + "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw=="], + + "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw=="], + + "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ=="], + + "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.5.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A=="], + + "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.5.1", "", { "os": "linux", "cpu": "none" }, "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ=="], + + "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.5.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg=="], + + "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.5.1", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA=="], + + "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg=="], + + "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA=="], + + "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@oxc-minify/binding-android-arm-eabi": ["@oxc-minify/binding-android-arm-eabi@0.136.0", "", { "os": "android", "cpu": "arm" }, "sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ=="], + + "@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.136.0", "", { "os": "android", "cpu": "arm64" }, "sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w=="], + + "@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.136.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg=="], + + "@oxc-minify/binding-darwin-x64": ["@oxc-minify/binding-darwin-x64@0.136.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A=="], + + "@oxc-minify/binding-freebsd-x64": ["@oxc-minify/binding-freebsd-x64@0.136.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg=="], + + "@oxc-minify/binding-linux-arm-gnueabihf": ["@oxc-minify/binding-linux-arm-gnueabihf@0.136.0", "", { "os": "linux", "cpu": "arm" }, "sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw=="], + + "@oxc-minify/binding-linux-arm-musleabihf": ["@oxc-minify/binding-linux-arm-musleabihf@0.136.0", "", { "os": "linux", "cpu": "arm" }, "sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ=="], + + "@oxc-minify/binding-linux-arm64-gnu": ["@oxc-minify/binding-linux-arm64-gnu@0.136.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w=="], + + "@oxc-minify/binding-linux-arm64-musl": ["@oxc-minify/binding-linux-arm64-musl@0.136.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA=="], + + "@oxc-minify/binding-linux-ppc64-gnu": ["@oxc-minify/binding-linux-ppc64-gnu@0.136.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw=="], + + "@oxc-minify/binding-linux-riscv64-gnu": ["@oxc-minify/binding-linux-riscv64-gnu@0.136.0", "", { "os": "linux", "cpu": "none" }, "sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw=="], + + "@oxc-minify/binding-linux-riscv64-musl": ["@oxc-minify/binding-linux-riscv64-musl@0.136.0", "", { "os": "linux", "cpu": "none" }, "sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ=="], + + "@oxc-minify/binding-linux-s390x-gnu": ["@oxc-minify/binding-linux-s390x-gnu@0.136.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w=="], + + "@oxc-minify/binding-linux-x64-gnu": ["@oxc-minify/binding-linux-x64-gnu@0.136.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw=="], + + "@oxc-minify/binding-linux-x64-musl": ["@oxc-minify/binding-linux-x64-musl@0.136.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA=="], + + "@oxc-minify/binding-openharmony-arm64": ["@oxc-minify/binding-openharmony-arm64@0.136.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ=="], + + "@oxc-minify/binding-wasm32-wasi": ["@oxc-minify/binding-wasm32-wasi@0.136.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA=="], + + "@oxc-minify/binding-win32-arm64-msvc": ["@oxc-minify/binding-win32-arm64-msvc@0.136.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw=="], + + "@oxc-minify/binding-win32-ia32-msvc": ["@oxc-minify/binding-win32-ia32-msvc@0.136.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng=="], + + "@oxc-minify/binding-win32-x64-msvc": ["@oxc-minify/binding-win32-x64-msvc@0.136.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yoQwSom8xsB+JdGsPUU0xxmxLKiF2kdlrK7I56WtGKZilixuBf/TmOwNYJYLRWkBoW5l2/pDZOhBm2luwmLiLw=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRIopPLvr3pf2Xj7f5LKyCuqzIU6zOS+zEIR8UDYhcgJyZHnvBkfrYnfcztyIcrGdQehrFUi3uplmI09E7RdiQ=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-a0EOFvnOd2FqmDSvH6uWLROSlU6KV/JDKbsYDA/zRLyKcG6HCsmFnPsp8iV7/xr9WMbNgyJi6R5IMpePQlUq7Q=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ikRYDHL3fOdZwfJKmcdqjlLgkeNZ3Ez0qM8wAev5zlHZ+lY/Ig7qG5SCqPlvuTu+nNQ6zrFFaKvvt69EBKXU/g=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dtRv5J5MRCLR7x39K8ufIIW4svIc7gYFUaI0YFXmmeOBhK/K2t/CkguPnDroKtsmXIPHDRtmJ1JJYzNcgJl6Wg=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IE4iiiggFH2snagQxHrY5bv6dDpRMMat+vdlMN/ibonA65eOmRLp8VLTXnDiNrcla/itJ1L9qGABHNKU+SnE8g=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wi9zQPMDHrBuRuT7Iurfidc9qlZh7cKa5vfYzOWNBCaqJdgxmNOFzvYen02wVUxSWGKhpiPHxrPX0jdRyJ8Npg=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-0tqqu1pqPee2lLGY8vtYlX1L415fFn89e0a3yp4q5N9f03j1rRs0R31qesTm3bt/UK8HYjECZ+56FCVPs2MEMQ=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-y36Hh1a5TA+oIGtlc8lT7N9vdHXBlhBetQJW0p457KbiVQ7jF7AZkaPWhESkjHWAsTVKD2OjCa9ZqfaqhSI0FQ=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7/acaG9htovp3gp/J0kHgbItQTuHctl+rbqPPqZ9DRBYTz8iV8kv3QN8t8Or8i/hOmOjfZp9McDoSU1duoR4/A=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AxFt0reY6Q2rfudABmMTFGR8tFFr58NlH2rRBQgcj+F+iEwgJ+jMwAPhXd2y1I2zaI8GspuahedUYQinqxWqjA=="], + + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.76.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-wHdkHdhf6AWBoO8vs5cpoR6zEFY1rB+fXWtq6j/xb9j/lu1evlujRVMkh8IM/M/pOUIrNkna3nzST/mRImiveQ=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-G7ZlEWcb2hNwCK3qalzqJoyB6HaTigQ/GEa7CU8sAJ/WwMdG/NnPqiC9IqpEAEy1ARSo4XMALfKbKNuqbSs5mg=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA=="], + + "@oxc-project/types": ["@oxc-project/types@0.76.0", "", {}, "sha512-CH3THIrSViKal8yV/Wh3FK0pFhp40nzW1MUDCik9fNuid2D/7JJXKJnfFOAvMxInGXDlvmgT6ACAzrl47TqzkQ=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@spinframework/build-tools": ["@spinframework/build-tools@2.1.1", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bytecodealliance/componentize-js": "^0.23.0", "@bytecodealliance/jco": "^1.17.9", "acorn-walk": "^8.3.4", "acron": "^1.0.5", "magic-string": "^0.30.17", "regexpu-core": "^6.2.0", "yargs": "^18.0.0" }, "bin": { "j2w": "dist/index.js" } }, "sha512-VF4NRtkqW0vUv3MzgwbyZKzG4qC1D2DrmFbr+P3uJPLq0stuLqcuASqq3LzrxTbTnpAeeswn8JhEe+wPbH1Lng=="], + + "@spinframework/spin-variables": ["@spinframework/spin-variables@1.0.1", "", { "peerDependencies": { "@spinframework/build-tools": ">=1.0.0 <3.0.0" } }, "sha512-PRapjCzTQSVSKpDcX8nNg/EJ/RWx6c+7PhtDGT0JCrqAM4G07+dvJLMjlKOssSsj0TMTxKfzEDzyXijFDXArAA=="], + + "@spinframework/wasi-http-proxy": ["@spinframework/wasi-http-proxy@2.0.0", "", { "peerDependencies": { "@spinframework/build-tools": "^2.0.0" } }, "sha512-nMW4QcueYajZ4fqcC3gTbfWuJv3hm8oc6sJfc5FI4y1hEOH1K7ciV6g+9R3EEmQl8GjeZhI5rdQ9Anz3N4NVqQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "acorn": ["acorn@8.17.0", "", { "bin": "bin/acorn" }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "acron": ["acron@1.0.5", "", { "dependencies": { "lodash": "^4.17.11" }, "bin": "cli.js" }, "sha512-Jm3uey9PSUl5k9RsjBSfDJG35nG0vd9B4FUCV1J/NeVHDQ5HJPVpAhmGrPYtPKh0gLhEUIEy4DKqBy/p60B+4Q=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "binaryen": ["binaryen@130.0.0", "", { "bin": { "wasm-as": "bin/wasm-as", "wasm-ctor-eval": "bin/wasm-ctor-eval", "wasm-dis": "bin/wasm-dis", "wasm-merge": "bin/wasm-merge", "wasm-metadce": "bin/wasm-metadce", "wasm-opt": "bin/wasm-opt", "wasm-reduce": "bin/wasm-reduce", "wasm-shell": "bin/wasm-shell", "wasm2js": "bin/wasm2js" } }, "sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "componentize-qjs": ["componentize-qjs@0.4.5", "", { "optionalDependencies": { "@andreiltd/componentize-qjs-binding-darwin-arm64": "0.4.5", "@andreiltd/componentize-qjs-binding-darwin-x64": "0.4.5", "@andreiltd/componentize-qjs-binding-linux-arm64-gnu": "0.4.5", "@andreiltd/componentize-qjs-binding-linux-arm64-musl": "0.4.5", "@andreiltd/componentize-qjs-binding-linux-x64-gnu": "0.4.5", "@andreiltd/componentize-qjs-binding-linux-x64-musl": "0.4.5", "@andreiltd/componentize-qjs-binding-win32-arm64-msvc": "0.4.5", "@andreiltd/componentize-qjs-binding-win32-x64-msvc": "0.4.5" }, "bin": "bin/componentize-qjs" }, "sha512-/Brt+/Ps7DQ1mYuUsDISTQTFSaZfCSoRHkHnh9Dm5WHCO6arc/vzVdD8TXssfyNex2Iv3UR8NHdahsqABPDh0w=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": "dist/cjs/src/bin.js" }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "oxc-minify": ["oxc-minify@0.136.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm-eabi": "0.136.0", "@oxc-minify/binding-android-arm64": "0.136.0", "@oxc-minify/binding-darwin-arm64": "0.136.0", "@oxc-minify/binding-darwin-x64": "0.136.0", "@oxc-minify/binding-freebsd-x64": "0.136.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.136.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.136.0", "@oxc-minify/binding-linux-arm64-gnu": "0.136.0", "@oxc-minify/binding-linux-arm64-musl": "0.136.0", "@oxc-minify/binding-linux-ppc64-gnu": "0.136.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.136.0", "@oxc-minify/binding-linux-riscv64-musl": "0.136.0", "@oxc-minify/binding-linux-s390x-gnu": "0.136.0", "@oxc-minify/binding-linux-x64-gnu": "0.136.0", "@oxc-minify/binding-linux-x64-musl": "0.136.0", "@oxc-minify/binding-openharmony-arm64": "0.136.0", "@oxc-minify/binding-wasm32-wasi": "0.136.0", "@oxc-minify/binding-win32-arm64-msvc": "0.136.0", "@oxc-minify/binding-win32-ia32-msvc": "0.136.0", "@oxc-minify/binding-win32-x64-msvc": "0.136.0" } }, "sha512-u9UvPFTYVwQ/i1kpp/c20S/SbvLaLV0Ngzyhgi0YV4B1SlxM7RgnnePl4vY9N3Z9sTRcozL1MRStI3PlUN9RFA=="], + + "oxc-parser": ["oxc-parser@0.76.0", "", { "dependencies": { "@oxc-project/types": "^0.76.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.76.0", "@oxc-parser/binding-darwin-arm64": "0.76.0", "@oxc-parser/binding-darwin-x64": "0.76.0", "@oxc-parser/binding-freebsd-x64": "0.76.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.76.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.76.0", "@oxc-parser/binding-linux-arm64-gnu": "0.76.0", "@oxc-parser/binding-linux-arm64-musl": "0.76.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.76.0", "@oxc-parser/binding-linux-s390x-gnu": "0.76.0", "@oxc-parser/binding-linux-x64-gnu": "0.76.0", "@oxc-parser/binding-linux-x64-musl": "0.76.0", "@oxc-parser/binding-wasm32-wasi": "0.76.0", "@oxc-parser/binding-win32-arm64-msvc": "0.76.0", "@oxc-parser/binding-win32-x64-msvc": "0.76.0" } }, "sha512-l98B2e9evuhES7zN99rb1QGhbzx25829TJFaKi2j0ib3/K/G5z1FdGYz6HZkrU3U8jdH7v2FC8mX1j2l9JrOUg=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.3", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": "bin/parser" }, "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg=="], + + "rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": "bin/cli.mjs" }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "typescript-compiler-api": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "@napi-rs/lzma-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@oxc-minify/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@oxc-minify/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@oxc-minify/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/examples/git/viewer/package.json b/examples/git/viewer/package.json new file mode 100644 index 0000000..aedd229 --- /dev/null +++ b/examples/git/viewer/package.json @@ -0,0 +1,42 @@ +{ + "name": "object-log-repo-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "packageManager": "bun@1.3.13", + "scripts": { + "build": "cargo build --locked --release --manifest-path ../../wal-component/Cargo.toml --target wasm32-wasip2 && bun run prepare && bun build.ts && j2w -i build/component.js -o build/viewer.wasm && mkdir -p dist && wac plug --plug ../../wal-component/target/wasm32-wasip2/release/object_log_component.wasm build/viewer.wasm -o dist/viewer.wasm", + "check": "bun run prepare && biome check . && tsc --noEmit", + "format": "biome check --write .", + "prepare": "bun prepare.ts", + "test": "bun test", + "start": "bun run build && OBJECT_LOG_GIT_LOCAL_MANIFEST=spin.viewer.toml ../../../scripts/run-git-local.sh" + }, + "devDependencies": { + "@biomejs/biome": "2.5.14", + "@bytecodealliance/preview2-shim": "0.26.0", + "@spinframework/build-tools": "2.1.1", + "@types/bun": "1.3.13", + "typescript": "7.0.2" + }, + "overrides": { + "@bytecodealliance/componentize-js": "https://github.com/carsonfarmer/ComponentizeJS/releases/download/opaque-resource-v0.23.0/bytecodealliance-componentize-js-0.23.0-opaque-resource.1.tgz" + }, + "dependencies": { + "@noble/hashes": "2.4.0", + "@spinframework/spin-variables": "1.0.1", + "@spinframework/wasi-http-proxy": "2.0.0", + "fflate": "0.8.3", + "preact": "10.29.8" + }, + "config": { + "witDependencies": [ + { + "witPath": "./build/wit", + "package": "object-log:storage@0.1.0", + "world": "reader" + } + ] + } +} diff --git a/examples/git/viewer/prepare.ts b/examples/git/viewer/prepare.ts new file mode 100644 index 0000000..e9c0d50 --- /dev/null +++ b/examples/git/viewer/prepare.ts @@ -0,0 +1,11 @@ +await Bun.write( + "build/wit/wal.wit", + `${await Bun.file("../../wal-component/wit/wal.wit").text()}\nworld reader { import wal; }\n`, +); +const types = Bun.spawn( + ["bun", "x", "--bun", "jco", "types", "build/wit", "-n", "reader", "-o", "build/types"], + { stdout: "inherit", stderr: "inherit" }, +); +if (await types.exited) throw new Error("WAL type generation failed"); + +export {}; diff --git a/examples/git/viewer/src/api.ts b/examples/git/viewer/src/api.ts new file mode 100644 index 0000000..03826f7 --- /dev/null +++ b/examples/git/viewer/src/api.ts @@ -0,0 +1,112 @@ +import { NotFound, snapshot } from "./repository"; +import { + automaticLogId, + Catalog, + canonicalName, + drop, + openExisting, + type Repository, + settings, + variable, +} from "./wal"; + +export async function browse(request: Request): Promise { + const query = new URL(request.url).searchParams; + const path = query.get("path") ?? ""; + if ( + [...query.keys()].some( + (key) => + !["repo", "ref", "commit", "path"].includes(key) || + query.getAll(key).length !== 1 || + (query.get(key)?.length ?? 0) > 4096, + ) || + (query.has("commit") && !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(query.get("commit") ?? "")) || + (path && + (path.split("/").length > 64 || + path + .split("/") + .some((part) => !part || part === "." || part === ".." || part.includes("\0")))) + ) + return new Response("Invalid browse query", { status: 400 }); + const input = query.get("repo") ?? "", + name = canonicalName(input); + if ( + !input || + input === "*" || + name === ".git" || + name.length > 4096 || + /[\s%?#\\]/.test(input) || + input.split("/").some((part) => !part || part === "." || part === "..") || + new URL(`/${name}`, request.url).pathname !== `/${name}` + ) + return new Response("Repository not found", { status: 404 }); + const repositories: Record> = JSON.parse( + variable("git_repositories"), + ); + const policy = + Object.entries(repositories).find(([key]) => key !== "*" && canonicalName(key) === name)?.[1] ?? + repositories["*"]; + if (!policy) return new Response("Repository not found", { status: 404 }); + const repository = { ...policy, log_id: policy.log_id || automaticLogId(name) }; + // Standard Git discovery enforces the unchanged service's repository permissions. + const authorized = await fetch( + `http://git.spin.internal/${name}/info/refs?service=git-upload-pack`, + { + headers: { Authorization: request.headers.get("Authorization") ?? "" }, + redirect: "manual", + }, + ); + const body = authorized.body?.getReader(); + try { + let bytes = 0; + for (let part = await body?.read(); part && !part.done; part = await body?.read()) { + bytes += part.value.length; + if (bytes > 8 << 20) throw new Error("Git discovery exceeds 8 MiB"); + } + } finally { + await body?.cancel(); + body?.releaseLock(); + } + if (authorized.status !== 200) + return new Response("Repository access denied", { + status: [401, 403, 404, 503].includes(authorized.status) ? authorized.status : 502, + }); + if ( + !authorized.headers + .get("Content-Type") + ?.startsWith("application/x-git-upload-pack-advertisement") + ) + return new Response("Invalid Git discovery response", { status: 502 }); + // Successful discovery has validated this same declaratively configured backend. + let session = openExisting(settings(repository)); + const budget = { bytes: 0 }; + try { + for (let attempt = 0; ; attempt++) { + let catalog: Catalog | undefined; + try { + catalog = new Catalog(session, repository.format, budget); + const view = snapshot(catalog, query), + usage = session.usage(); + return Response.json(view, { + headers: { + "Cache-Control": "no-store", + "X-Wal-Calls": String(usage.calls), + "X-Wal-Bytes": String(usage.bytes), + }, + }); + } catch (error) { + if (error instanceof NotFound) + return new Response("Branch, commit or path not found", { status: 404 }); + if (attempt === 0 && (error as { payload?: { tag: string } }).payload?.tag === "expired") { + const fresh = session.refresh(); + drop(session); + session = fresh; + } else throw error; + } finally { + catalog?.close(); + } + } + } finally { + drop(session); + } +} diff --git a/examples/git/viewer/src/client.tsx b/examples/git/viewer/src/client.tsx new file mode 100644 index 0000000..7b6e699 --- /dev/null +++ b/examples/git/viewer/src/client.tsx @@ -0,0 +1,393 @@ +import { render, type VNode } from "preact"; +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; + +import type { Commit, Snapshot } from "./repository"; + +const messages: Record = { + 400: "Invalid repository, branch, commit or path.", + 401: "Authentication required.", + 403: "This credential cannot read this repository.", + 404: "Repository, commit or path not found.", +}; + +function Icon({ kind }: { kind: "folder" | "file" | "code" | "chevron" }) { + const paths = { + folder: "M2 4h5l2 2h5v7H2z", + file: "M4 2h5l3 3v9H4zM9 2v4h3", + code: "M6 4 2 8l4 4m4-8 4 4-4 4", + chevron: "m4 6 4 4 4-4", + }; + return ( + + ); +} + +function validRepository(name: string): boolean { + return ( + name !== "" && + name !== "*" && + name !== ".git" && + !/[\s%?#\\]/.test(name) && + name.split("/").every((part) => part !== "" && part !== "." && part !== "..") && + new URL(`/${name}`, location.origin).pathname === `/${name}` + ); +} + +function History({ commits, link }: { commits: Commit[]; link: (commit: Commit) => VNode }) { + return ( +
+

+ Recent commits · first parent · select a commit to explore earlier history +

+
+ {commits.length ? ( + commits.map((commit) => ( +
+
+ {link(commit)} +

+ {commit.author || "Unknown author"} committed on{" "} + +

+
+
+ )) + ) : ( +

No commits yet.

+ )} +
+
+ ); +} + +function Code({ + view, + repository, + link, + commitLink, +}: { + view: Snapshot; + repository: string; + link: (label: string, path: string) => VNode; + commitLink: (commit: Commit) => VNode; +}) { + return ( +
+ +
+ {view.history[0] &&
{commitLink(view.history[0])}
} + {view.file ? ( + <> +
+ {view.file.size.toLocaleString()} bytes + {view.file.id.slice(0, 12)} +
+ {view.file.state === "text" ? ( +
{view.file.text}
+ ) : ( +

+ {view.file.state === "binary" + ? "Binary file · preview unavailable" + : view.file.state === "large" + ? "This file exceeds the 256 KiB preview limit." + : `Submodule · ${view.file.id}`} +

+ )} + + ) : !view.history.length ? ( +

This repository is empty. Push a branch to start exploring.

+ ) : ( + <> + {view.path && ( +
+ + {link("..", view.path.split("/").slice(0, -1).join("/"))} +
+ )} + {[...view.entries] + .sort( + (a, b) => + Number(b.kind === "directory") - Number(a.kind === "directory") || + a.name.localeCompare(b.name), + ) + .map((entry) => ( +
+ + {entry.unavailable ? ( + {entry.name} + ) : ( + link(entry.name, [view.path, entry.name].filter(Boolean).join("/")) + )} + {entry.id.slice(0, 7)} +
+ ))} + {!view.entries.length &&

This directory is empty.

} + {view.more &&

Showing the first 500 entries.

} + + )} +
+
+ ); +} + +function App() { + const [search, setSearch] = useState(location.search); + const [authorization, setAuthorization] = useState({ header: "" }); + const [result, setResult] = useState<{ view?: Snapshot; error?: string; status?: number }>({}); + const [tab, setTab] = useState("code"); + const [copied, setCopied] = useState(false); + const cloneInput = useRef(null); + const query = new URLSearchParams(search); + const repository = query.get("repo") ?? ""; + const valid = validRepository(repository); + const view = result.view; + const cloneURL = `${location.origin}/${repository}`; + + useEffect(() => { + const update = () => setSearch(location.search); + addEventListener("popstate", update); + return () => removeEventListener("popstate", update); + }, []); + + useLayoutEffect(() => { + document.title = repository ? `${repository} · object-log` : "Repository · object-log"; + if (!valid) return; + const request = new AbortController(); + setResult({}); + void (async () => { + try { + const params = new URLSearchParams(search); + const response = await fetch(`/_viewer/api?${params}`, { + headers: authorization.header ? { Authorization: authorization.header } : {}, + credentials: "omit", + cache: "no-store", + signal: request.signal, + }); + if (!response.ok) { + if (!request.signal.aborted) + setResult({ + error: messages[response.status] ?? "Repository unavailable. Try again.", + status: response.status, + }); + return; + } + const snapshot: Snapshot = await response.json(); + if (!request.signal.aborted) setResult({ view: snapshot }); + } catch (error) { + if (!request.signal.aborted) + setResult({ error: error instanceof Error ? error.message : "Repository unavailable." }); + } + })(); + return () => request.abort(); + }, [search, repository, valid, authorization]); + + function navigate(params: URLSearchParams) { + history.pushState(null, "", `?${params}`); + setSearch(location.search); + } + function link(params: URLSearchParams, label: string | VNode, className?: string) { + return ( + { + if (event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) + return; + event.preventDefault(); + navigate(params); + }} + > + {label} + + ); + } + function pathLink(label: string, path: string) { + const params = new URLSearchParams(search); + params.set("path", path); + return link(params, label); + } + function commitLink(commit: Commit) { + return link( + new URLSearchParams({ repo: repository, ref: view?.branch ?? "", commit: commit.id }), + <> + {commit.id.slice(0, 7)} + + {commit.title || "Untitled commit"} + + , + "commit-link", + ); + } + + return ( + <> +
+ ◈ object-log + Repository explorer +
+
+

{repository.replace(/\.git$/, "") || "Repositories"}

+ {!valid ? ( +
{ + event.preventDefault(); + const name = String(new FormData(event.currentTarget).get("repo")).trim(); + if (validRepository(name)) navigate(new URLSearchParams({ repo: name })); + else setResult({ error: "Enter a repository path such as team/project.git." }); + }} + > + + +

Open a repository. A first push creates it at the same Git URL.

+

{result.error}

+
+ ) : !view ? ( + <> +

{result.error ?? "Loading repository…"}

+ {result.status === 404 && + !query.get("ref") && + !query.get("commit") && + !query.get("path") && ( +

+ Push your first branch to create this repository: +
+ git push {cloneURL} HEAD +

+ )} + {result.error && ( +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const credential = String(data.get("credential")); + event.currentTarget.reset(); + const encoded = btoa( + Array.from(new TextEncoder().encode(`git:${credential}`), (byte) => + String.fromCharCode(byte), + ).join(""), + ); + setAuthorization({ + header: + data.get("mode") === "bearer" ? `Bearer ${credential}` : `Basic ${encoded}`, + }); + }} + > +

Sign in to read this repository

+ + + +

Used for this page only.

+
+ )} + + ) : ( + <> + +
+ + + {view.branches.length} {view.branches.length === 1 ? "branch" : "branches"} + + {query.has("commit") && + link(new URLSearchParams({ repo: repository, ref: view.branch }), "Branch tip")} +
+ + Code + +
+ + +
+
+
+ {tab === "commits" ? ( + + ) : ( + + )} + + )} +
+ + ); +} + +const root = document.getElementById("app"); +if (!root) throw new Error("Missing app root"); +render(, root); diff --git a/examples/git/viewer/src/index.ts b/examples/git/viewer/src/index.ts new file mode 100644 index 0000000..8ca0242 --- /dev/null +++ b/examples/git/viewer/src/index.ts @@ -0,0 +1,48 @@ +import { browse } from "./api"; + +declare const CLIENT_SCRIPT: string; +declare const STYLES: string; +declare const PAGE: string; + +async function handle(request: Request): Promise { + const path = new URL(request.url).pathname; + if (path === "/_viewer/api") { + if (request.method !== "GET" && request.method !== "HEAD") + return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } }); + return browse(request); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "GET, HEAD" }, + }); + } + if (path !== "/" && path !== "/_viewer/client.js" && path !== "/_viewer/style.css") { + return new Response("Not found", { status: 404 }); + } + const assets: Record = { + "/_viewer/client.js": [CLIENT_SCRIPT, "text/javascript; charset=utf-8"], + "/_viewer/style.css": [STYLES, "text/css; charset=utf-8"], + }; + const [body, type] = assets[path] ?? [PAGE, "text/html; charset=utf-8"]; + return new Response(request.method === "HEAD" ? null : body, { + headers: { + "Content-Type": type, + "Cache-Control": "no-cache", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", + }, + }); +} + +addEventListener("fetch", (event: Event) => { + const request = event as FetchEvent; + request.respondWith( + handle(request.request).catch((error) => { + console.error(error); + return new Response("Repository unavailable", { status: 503 }); + }), + ); +}); diff --git a/examples/git/viewer/src/page.html b/examples/git/viewer/src/page.html new file mode 100644 index 0000000..9cb5d23 --- /dev/null +++ b/examples/git/viewer/src/page.html @@ -0,0 +1,11 @@ + + + + + + Repository · object-log + + + +
+ diff --git a/examples/git/viewer/src/repository.ts b/examples/git/viewer/src/repository.ts new file mode 100644 index 0000000..de943b5 --- /dev/null +++ b/examples/git/viewer/src/repository.ts @@ -0,0 +1,152 @@ +import { type Catalog, hex, MissingObject } from "./wal"; + +export interface Commit { + id: string; + title: string; + author: string; + date: string; +} +interface TreeEntry { + name: string; + kind: string; + id: string; + unavailable?: true; +} +export interface Snapshot { + branch: string; + branches: string[]; + path: string; + history: Commit[]; + entries: TreeEntry[]; + more: boolean; + file?: { id: string; size: number; state: string; text?: string }; +} +const text = new TextDecoder(); +const strictText = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const previewBytes = 256 << 10; +const metadataBytes = 16 << 20; +export class NotFound extends Error {} + +// Decode only loose commit/tree objects; packs and deltas remain the Git service's concern. +function commit(catalog: Catalog, id: string) { + const raw = text.decode(catalog.object(id, 1, metadataBytes).bytes); + const split = raw.indexOf("\n\n"), + header = raw.slice(0, split).split("\n"); + const fields = (name: string) => + header.find((line) => line.startsWith(`${name} `))?.slice(name.length + 1); + const author = /^(.*?) <.*>[ \t]+(-?\d+)[ \t]+[+-]\d{4}$/.exec(fields("author") ?? ""); + const tree = fields("tree"); + if (split < 0 || !tree || !author) throw new Error("Invalid Git commit"); + return { + tree, + parent: fields("parent") ?? "", + summary: { + id, + title: raw + .slice(split + 2) + .split("\n", 1)[0] + .slice(0, 512), + author: author[1].slice(0, 512), + date: new Date(Number(author[2]) * 1000).toISOString(), + }, + }; +} + +function tree(catalog: Catalog, id: string): TreeEntry[] { + const bytes = catalog.object(id, 2, metadataBytes).bytes; + if (!bytes) throw new Error("Tree exceeds metadata limit"); + const width = catalog.root.Format === "sha256" ? 32 : 20, + result: TreeEntry[] = []; + for (let offset = 0; offset < bytes.length; ) { + const space = bytes.indexOf(32, offset), + nul = bytes.indexOf(0, space + 1); + if (space < offset || nul <= space || nul + 1 + width > bytes.length) + throw new Error("Invalid Git tree"); + const mode = text.decode(bytes.subarray(offset, space)), + rawName = bytes.subarray(space + 1, nul); + let name: string, + unavailable = false; + try { + name = strictText.decode(rawName); + } catch { + name = hex(rawName).replace(/../g, "\\x$&"); + unavailable = true; + } + if (!name || name === "." || name === ".." || name.includes("/")) + throw new Error("Invalid Git name"); + result.push({ + name, + kind: mode === "40000" ? "directory" : mode === "160000" ? "submodule" : "file", + id: hex(bytes.subarray(nul + 1, nul + 1 + width)), + ...(unavailable ? { unavailable: true as const } : {}), + }); + offset = nul + 1 + width; + } + return result; +} + +export function snapshot(catalog: Catalog, query: URLSearchParams): Snapshot { + const branches = Object.keys(catalog.root.Refs) + .filter((ref) => ref.startsWith("refs/heads/")) + .sort(); + const branch = + query.get("ref") || + (branches.includes(catalog.root.Head) ? catalog.root.Head : branches[0]) || + ""; + const view: Snapshot = { + branch, + branches, + path: query.get("path") ?? "", + history: [], + entries: [], + more: false, + }; + const selected = query.get("commit"); + if (!branches.length && !query.get("ref") && !selected && !view.path) return view; + if (!branches.includes(branch)) throw new NotFound(); + if (selected && selected.length !== (catalog.root.Format === "sha256" ? 64 : 40)) + throw new NotFound(); + let id = selected || catalog.root.Refs[branch], + tip = ""; + for (let i = 0; id && i < 8; i++) { + let current: ReturnType; + try { + current = commit(catalog, id); + } catch (error) { + if (i === 0 && selected && error instanceof MissingObject) throw new NotFound(); + throw error; + } + if (i === 0) tip = current.tree; + view.history.push(current.summary); + id = current.parent; + } + let entries = tree(catalog, tip); + const parts = view.path ? view.path.split("/") : []; + for (const [index, part] of parts.entries()) { + const entry = entries.find((value) => !value.unavailable && value.name === part); + if (!entry) throw new NotFound(); + if (entry.kind === "directory") { + entries = tree(catalog, entry.id); + continue; + } + if (index !== parts.length - 1) throw new NotFound(); + view.file = { id: entry.id, size: 0, state: "submodule" }; + if (entry.kind !== "submodule") { + const blob = catalog.object(entry.id, 3, previewBytes); + view.file.size = blob.size; + view.file.state = blob.bytes ? "binary" : "large"; + if (blob.bytes && !blob.bytes.includes(0)) { + try { + view.file.text = strictText.decode(blob.bytes); + view.file.state = "text"; + } catch { + /* Binary preview. */ + } + } + } + return view; + } + view.more = entries.length > 500; + view.entries = entries.slice(0, 500); + return view; +} diff --git a/examples/git/viewer/src/style.css b/examples/git/viewer/src/style.css new file mode 100644 index 0000000..4299a58 --- /dev/null +++ b/examples/git/viewer/src/style.css @@ -0,0 +1,279 @@ +:root { + color-scheme: light; + font: + 14px / 1.5 system-ui, + sans-serif; + color: #1f2328; + background: white; +} +* { + box-sizing: border-box; +} +svg { + flex-shrink: 0; +} +body { + margin: 0; +} +a { + color: #0969da; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +button, +input, +select, +summary { + font: inherit; + padding: 6px 12px; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #f6f8fa; + color: inherit; +} +button, +select, +summary { + cursor: pointer; +} +input { + background: white; + min-width: 0; +} +:focus-visible { + outline: 2px solid #0969da; + outline-offset: 2px; +} +header { + display: flex; + gap: 24px; + padding: 20px 32px; + background: #f6f8fa; + border-bottom: 1px solid #d0d7de; +} +header a { + color: inherit; + font-weight: 650; +} +header span, +.muted, +code, +small { + color: #656d76; +} +main { + max-width: 1120px; + margin: auto; + padding: 32px; +} +h1 { + display: flex; + align-items: center; + gap: 16px; + margin: 0 0 24px; + font-size: 22px; + color: #0969da; + overflow-wrap: anywhere; +} +small { + font-size: 12px; + font-weight: 400; + border: 1px solid #d0d7de; + border-radius: 20px; + padding: 2px 8px; + white-space: nowrap; +} +h2 { + margin: 0; + font-size: 16px; +} +.panel { + border: 1px solid #d0d7de; + border-radius: 6px; + overflow: hidden; +} +p.panel { + padding: 16px 24px; + overflow-wrap: anywhere; +} +.panel + .panel, +p[role="status"] + .panel { + margin-top: 16px; +} +form.panel { + display: grid; + gap: 12px; + max-width: 520px; + padding: 24px; +} +label { + display: grid; + gap: 6px; +} +form button { + justify-self: start; +} +summary, +form button { + background: #1f883d; + color: white; + border-color: #1f883d; +} +p { + margin: 0; +} +.tabs { + display: flex; + gap: 8px; + border-bottom: 1px solid #d0d7de; + margin-bottom: 20px; +} +.tabs button { + border: 0; + border-radius: 0; + padding: 10px 16px; + background: transparent; + border-bottom: 2px solid transparent; +} +.tabs [aria-pressed="true"] { + border-bottom-color: #fd8c73; + font-weight: 600; +} +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} +.toolbar label { + display: flex; + align-items: center; +} +.toolbar select { + max-width: 220px; +} +.select-control { + position: relative; + display: inline-flex; +} +.select-control select { + appearance: none; + -webkit-appearance: none; + padding-right: 32px; +} +.select-control svg { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; +} +summary { + display: flex; + align-items: center; + gap: 8px; + list-style: none; +} +summary::-webkit-details-marker { + display: none; +} +summary svg:last-child { + margin-left: 8px; +} +details { + position: relative; + margin-left: auto; +} +.clone-box { + position: absolute; + right: 0; + z-index: 1; + width: min(340px, calc(100vw - 32px)); + padding: 16px; + background: white; + border: 1px solid #d0d7de; + border-radius: 6px; +} +.clone-box button { + margin-top: 8px; +} +.breadcrumbs { + margin-bottom: 12px; + overflow-wrap: anywhere; +} +article { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid #d0d7de; +} +article:last-child { + border-bottom: 0; +} +article > a, +article > div, +article > strong { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} +.tip { + background: #f6f8fa; + justify-content: space-between; +} +.commit-link { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.commit-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} +code, +pre { + font: + 12px / 1.65 ui-monospace, + monospace; +} +.commit-link code { + flex-shrink: 0; +} +pre { + margin: 0; + padding: 20px; + overflow: auto; + tab-size: 4; +} +.empty { + padding: 40px 24px; + color: #656d76; + text-align: center; +} +@media (max-width: 600px) { + header { + padding: 16px; + } + header span { + display: none; + } + main { + padding: 24px 16px; + } + h1 { + font-size: 19px; + } + .toolbar { + gap: 8px; + } + article > code { + display: none; + } +} diff --git a/examples/git/viewer/src/wal.test.ts b/examples/git/viewer/src/wal.test.ts new file mode 100644 index 0000000..891920e --- /dev/null +++ b/examples/git/viewer/src/wal.test.ts @@ -0,0 +1,525 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import type { Config, Session } from "object-log:storage/wal@0.1.0"; +import { zlibSync } from "fflate"; + +let opened = 0; +const defaultPolicies = '{"team/demo.git":{"log_id":"demo","format":"sha256"}}'; +let policies = defaultPolicies; +let openSession: (config: Config) => Session = () => { + throw new Error("Unexpected storage access"); +}; +mock.module("object-log:storage/wal@0.1.0", () => ({ + openExisting: (config: Config) => { + opened++; + return openSession(config); + }, +})); +mock.module("@spinframework/spin-variables", () => ({ + get: (name: string) => (name === "git_repositories" ? policies : ""), +})); +const { Catalog, MissingObject } = await import("./wal"); +const { browse } = await import("./api"); +const { NotFound, snapshot } = await import("./repository"); +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + policies = defaultPolicies; + openSession = () => { + throw new Error("Unexpected storage access"); + }; +}); + +function emptySession(format: string, overrides: Record = {}): Session { + const dispose = () => undefined; + let next = true; + return { + recover: () => ({ + next: () => { + if (!next) return undefined; + next = false; + return { tag: "checkpoint", val: { objects: [{ [Symbol.dispose]: dispose }] } }; + }, + readNode: () => ({ + data: new TextEncoder().encode( + JSON.stringify({ + Validated: true, + Format: format, + Head: "refs/heads/main", + Refs: {}, + Buckets: [], + ...overrides, + }), + ), + objects: [], + }), + [Symbol.dispose]: dispose, + }), + usage: () => ({ calls: 0n, bytes: 0n }), + [Symbol.dispose]: dispose, + } as unknown as Session; +} + +const discovery = () => + new Response("refs", { + headers: { "Content-Type": "application/x-git-upload-pack-advertisement" }, + }); + +test("catalog requires a persisted branch HEAD and accepts an unborn branch", () => { + for (const format of ["sha1", "sha256"]) { + for (const Head of [undefined, "", "refs/tags/main", "refs/heads/"]) + expect(() => new Catalog(emptySession(format, { Head }), "", { bytes: 0 })).toThrow( + "Invalid repository catalog", + ); + const catalog = new Catalog(emptySession(format), "", { bytes: 0 }); + expect(snapshot(catalog, new URLSearchParams()).branches).toEqual([]); + catalog.close(); + } +}); + +test("wildcard repositories recover their stored format and canonical WAL identity", async () => { + policies = '{"*":{}}'; + for (const format of ["sha1", "sha256"]) { + policies = format === "sha1" ? '{"*":{}}' : '{"*":{},"team/project":{"log_id":"","format":""}}'; + for (const name of ["team/project", "team/project.git", "team/project.GIT"]) { + const canonical = name === "team/project.GIT" ? "team/project.GIT.git" : "team/project.git"; + let authorized = false; + globalThis.fetch = mock(async (url: RequestInfo | URL) => { + expect(String(url)).toBe( + `http://git.spin.internal/${canonical}/info/refs?service=git-upload-pack`, + ); + authorized = true; + return discovery(); + }) as unknown as typeof fetch; + openSession = (config) => { + expect(authorized).toBe(true); + expect(config.logId).toBe( + `auto-${new Bun.CryptoHasher("sha256").update(canonical).digest("hex")}`, + ); + return emptySession(format); + }; + expect( + (await browse(new Request(`https://viewer.test/_viewer/api?repo=${name}`))).status, + ).toBe(200); + } + } +}); + +test("exact aliases override the whole wildcard policy and enforce pinned formats", async () => { + globalThis.fetch = mock(async () => discovery()) as unknown as typeof fetch; + for (const key of ["team/project", "team/project.git"]) { + policies = JSON.stringify({ + "*": { format: "sha256" }, + [key]: { log_id: "custom", format: "" }, + }); + openSession = (config) => { + expect(config.logId).toBe("custom"); + return emptySession("sha1"); + }; + for (const name of ["team/project", "team/project.git"]) + expect( + (await browse(new Request(`https://viewer.test/_viewer/api?repo=${name}`))).status, + ).toBe(200); + } + policies = '{"*":{"format":"sha256"}}'; + openSession = () => emptySession("sha1"); + await expect( + browse(new Request("https://viewer.test/_viewer/api?repo=team/project")), + ).rejects.toThrow("Invalid repository catalog"); + policies = '{"*":{}}'; + openSession = () => emptySession("md5"); + await expect( + browse(new Request("https://viewer.test/_viewer/api?repo=team/project")), + ).rejects.toThrow("Invalid repository catalog"); +}); + +function fixture( + format: "sha1" | "sha256", + raw: Uint8Array, + encoded: Uint8Array, + storage: "inline" | "full" | "delta" = "inline", +) { + const id = new Bun.CryptoHasher(format).update(raw).digest("hex"); + let drops = 0, + reads = 0; + const handle = () => ({ + [Symbol.dispose]() { + drops++; + }, + }); + const root = handle(), + leaf = handle(), + wrapper = handle(), + full = handle(), + delta = handle(); + const item = { + ID: id, + Kind: 3, + Size: 3, + StoredSize: encoded.length, + Encoding: "zlib", + ...(storage === "inline" + ? { Inline: btoa(String.fromCharCode(...encoded)) } + : storage === "delta" + ? { Delta: { StoredSize: 12 } } + : {}), + }; + const entry = (value: unknown, children: unknown[]) => ({ + data: new TextEncoder().encode(JSON.stringify(value)), + objects: children, + }); + let history = true; + const recovery = { + next() { + if (!history) return undefined; + history = false; + return { tag: "checkpoint", val: { objects: [root] } }; + }, + readNode(value: unknown) { + if (value === root) + return entry( + { + Validated: true, + Format: format, + Head: "refs/heads/main", + Refs: {}, + Buckets: [id.slice(0, 2)], + }, + [leaf], + ); + if (value === leaf) + return entry( + { Items: [item] }, + storage === "inline" ? [] : storage === "full" ? [full] : [wrapper], + ); + return { data: new Uint8Array(), objects: [full, delta] }; + }, + openBytes(value: unknown) { + expect(value).toBe(full); + return { + length: () => BigInt(encoded.length), + readAt(offset: bigint, length: number) { + reads++; + return encoded.slice(Number(offset), Number(offset) + Math.min(3, length)); + }, + [Symbol.dispose]() { + drops++; + }, + }; + }, + [Symbol.dispose]() { + drops++; + }, + }; + const catalog = new Catalog({ recover: () => recovery } as unknown as Session, format, { + bytes: 0, + }); + return { catalog, id, counts: () => ({ drops, reads }) }; +} + +for (const format of ["sha1", "sha256"] as const) { + test(`${format}: inline and chunked full objects with a retained delta`, () => { + const raw = new TextEncoder().encode("blob 3\0abc"), + encoded = zlibSync(raw); + for (const storage of ["inline", "full", "delta"] as const) { + const sample = fixture(format, raw, encoded, storage); + try { + expect(new TextDecoder().decode(sample.catalog.object(sample.id, 3, 256).bytes)).toBe( + "abc", + ); + } finally { + sample.catalog.close(); + } + expect(sample.counts().drops).toBe(storage === "inline" ? 3 : storage === "full" ? 5 : 7); + expect(sample.counts().reads).toBe(storage === "inline" ? 0 : Math.ceil(encoded.length / 3)); + } + }); +} + +test("inflation rejects excess decoded bytes and truncated input", () => { + const raw = new TextEncoder().encode("blob 3\0abc"); + for (const encoded of [ + zlibSync(new TextEncoder().encode("blob 3\0abcEXTRA")), + zlibSync(raw).slice(0, 5), + ]) { + const sample = fixture("sha256", raw, encoded); + try { + expect(() => sample.catalog.object(sample.id, 3, 256)).toThrow(); + } finally { + sample.catalog.close(); + } + expect(sample.counts().drops).toBe(3); + } +}); + +test("large preview reads only metadata", () => { + const raw = new TextEncoder().encode("blob 3\0abc"), + sample = fixture("sha256", raw, zlibSync(raw), "delta"); + try { + expect(sample.catalog.object(sample.id, 3, 2)).toEqual({ size: 3 }); + } finally { + sample.catalog.close(); + } + expect(sample.counts().reads).toBe(0); +}); + +test("a catalogued blob cannot be selected as a commit", () => { + const raw = new TextEncoder().encode("blob 3\0abc"), + sample = fixture("sha256", raw, zlibSync(raw)); + sample.catalog.root.Refs["refs/heads/main"] = "22".repeat(32); + try { + expect(() => snapshot(sample.catalog, new URLSearchParams({ commit: sample.id }))).toThrow( + NotFound, + ); + } finally { + sample.catalog.close(); + } + expect(sample.counts()).toEqual({ reads: 0, drops: 3 }); +}); + +test("Git authorization completes before viewer storage is opened", async () => { + const baseline = opened; + globalThis.fetch = mock(async (input: RequestInfo | URL, options?: RequestInit) => { + expect(String(input)).toBe( + "http://git.spin.internal/team/demo.git/info/refs?service=git-upload-pack", + ); + expect(options?.headers).toEqual({ Authorization: "Bearer test-token" }); + expect(options?.redirect).toBe("manual"); + return new Response("Denied", { status: 403 }); + }) as unknown as typeof fetch; + const response = await browse( + new Request(`https://viewer.test/_viewer/api?repo=team/demo.git&commit=${"11".repeat(32)}`, { + headers: { Authorization: "Bearer test-token" }, + }), + ); + expect(response.status).toBe(403); + expect(opened).toBe(baseline); + globalThis.fetch = mock(async () => { + throw new Error("Unexpected HTTP call"); + }) as unknown as typeof fetch; + expect( + (await browse(new Request("https://viewer.test/_viewer/api?repo=missing.git"))).status, + ).toBe(404); + expect( + (await browse(new Request("https://viewer.test/_viewer/api?repo=team/demo.git&path=.."))) + .status, + ).toBe(400); + expect(opened).toBe(baseline); +}); + +test("commit queries reject malformed selectors before authorization and return 404 for missing IDs", async () => { + const baseline = opened; + globalThis.fetch = mock(async () => { + throw new Error("Unexpected discovery"); + }) as unknown as typeof fetch; + for (const selector of [ + "", + "1234567", + "AA".repeat(20), + "11".repeat(21), + `${"11".repeat(20)}&commit=${"22".repeat(20)}`, + ]) + expect( + ( + await browse( + new Request(`https://viewer.test/_viewer/api?repo=team/demo.git&commit=${selector}`), + ) + ).status, + ).toBe(400); + expect(opened).toBe(baseline); + globalThis.fetch = mock(async () => discovery()) as unknown as typeof fetch; + openSession = () => emptySession("sha256", { Refs: { "refs/heads/main": "22".repeat(32) } }); + for (const selector of ["11".repeat(32), "11".repeat(20)]) + expect( + ( + await browse( + new Request(`https://viewer.test/_viewer/api?repo=team/demo.git&commit=${selector}`), + ) + ).status, + ).toBe(404); +}); + +for (const format of ["sha1", "sha256"] as const) { + test(`${format}: commit selection lazily reads its history and requested path without scanning the branch tip`, () => { + const width = format === "sha1" ? 20 : 32, + id = (n: number) => n.toString(16).padStart(width * 2, "0"), + encode = (value: string) => new TextEncoder().encode(value); + const objects = new Map(); + for (let i = 1; i <= 12; i++) + objects.set(id(i), { + kind: 1, + bytes: encode( + `tree ${id(20)}\n${i > 1 ? `parent ${id(i - 1)}\n` : ""}author A 1700000000 +0000\n\nCommit ${i}\n`, + ), + }); + const tree = (mode: string, name: string, target: number) => { + const header = encode(`${mode} ${name}\0`), + bytes = new Uint8Array(header.length + width); + bytes.set(header); + bytes[bytes.length - 1] = target; + return bytes; + }; + objects.set(id(20), { kind: 2, bytes: tree("40000", "src", 21) }); + objects.set(id(21), { kind: 2, bytes: tree("100644", "README", 22) }); + objects.set(id(22), { kind: 3, bytes: encode("selected content") }); + const reads: string[] = []; + const catalog = { + root: { Format: format, Head: "refs/heads/main", Refs: { "refs/heads/main": id(12) } }, + object(key: string, kind: number) { + reads.push(key); + const object = objects.get(key); + if (!object || object.kind !== kind) throw new MissingObject(); + return { size: object.bytes.length, bytes: object.bytes }; + }, + } as unknown as InstanceType; + const view = snapshot(catalog, new URLSearchParams({ commit: id(9), path: "src/README" })); + expect(view.branch).toBe("refs/heads/main"); + expect(view.history.map((commit) => commit.title)).toEqual( + Array.from({ length: 8 }, (_, i) => `Commit ${9 - i}`), + ); + expect(view.file?.text).toBe("selected content"); + expect(reads).toEqual([ + ...Array.from({ length: 8 }, (_, i) => id(9 - i)), + id(20), + id(21), + id(22), + ]); + reads.length = 0; + expect( + snapshot(catalog, new URLSearchParams({ commit: id(2), path: "src" })).history.map( + (commit) => commit.id, + ), + ).toEqual([id(2), id(1)]); + expect(reads).toEqual([id(2), id(1), id(20), id(21)]); + for (const commit of [id(99), id(22)]) + expect(() => snapshot(catalog, new URLSearchParams({ commit }))).toThrow(NotFound); + expect(() => + snapshot(catalog, new URLSearchParams({ ref: "refs/heads/missing", commit: id(9) })), + ).toThrow(NotFound); + }); +} + +test("unusual filenames remain listed without hiding valid siblings", () => { + const encode = (value: string) => new TextEncoder().encode(value); + const commitId = "11".repeat(20), + treeId = "22".repeat(20), + blobId = "33".repeat(20); + const names = [ + Uint8Array.of(255), + encode("�"), + encode("\\xff"), + encode("\uFEFFREADME"), + encode("README"), + ]; + const tree = new Uint8Array(names.reduce((sum, name) => sum + 8 + name.length + 20, 0)); + let offset = 0; + for (const [index, name] of names.entries()) { + tree.set(encode("100644 "), offset); + offset += 7; + tree.set(name, offset); + offset += name.length; + tree[offset++] = 0; + tree.fill(0x33 + index, offset, offset + 20); + offset += 20; + } + for (const spacing of [" ", " ", " \t"]) { + const catalog = { + root: { Format: "sha1", Head: "refs/heads/main", Refs: { "refs/heads/main": commitId } }, + object(id: string) { + const bytes = + id === commitId + ? encode(`tree ${treeId}\nauthor A ${spacing}1700000000 +0000\n\nTitle\n`) + : id === treeId + ? tree + : encode("safe"); + return { size: bytes.length, bytes }; + }, + } as unknown as InstanceType; + const view = snapshot(catalog, new URLSearchParams()); + expect(view.history[0].title).toBe("Title"); + expect(view.entries).toEqual([ + { name: "\\xff", kind: "file", id: blobId, unavailable: true }, + { name: "�", kind: "file", id: "34".repeat(20) }, + { name: "\\xff", kind: "file", id: "35".repeat(20) }, + { name: "\uFEFFREADME", kind: "file", id: "36".repeat(20) }, + { name: "README", kind: "file", id: "37".repeat(20) }, + ]); + expect(snapshot(catalog, new URLSearchParams({ path: "�" })).file?.text).toBe("safe"); + expect(snapshot(catalog, new URLSearchParams({ path: "\\xff" })).file?.id).toBe( + "35".repeat(20), + ); + } +}); + +test("expired reads refresh once, close both views, and retain cumulative usage", async () => { + let calls = 0, + bytes = 0, + refreshes = 0; + const closed: string[] = []; + const makeSession = (fresh: boolean): Session => + ({ + recover() { + let next = true; + const root = { + [Symbol.dispose]() { + closed.push(`root:${fresh}`); + }, + }; + return { + next() { + if (!next) return undefined; + next = false; + return { tag: "checkpoint", val: { objects: [root] } }; + }, + readNode() { + calls++; + bytes += 100; + if (!fresh) throw { payload: { tag: "expired" } }; + return { + objects: [], + data: new TextEncoder().encode( + JSON.stringify({ + Validated: true, + Format: "sha256", + Head: "refs/heads/main", + Refs: {}, + Buckets: [], + }), + ), + }; + }, + [Symbol.dispose]() { + closed.push(`recovery:${fresh}`); + }, + }; + }, + refresh() { + refreshes++; + return makeSession(true); + }, + usage: () => ({ calls: BigInt(calls), bytes: BigInt(bytes) }), + [Symbol.dispose]() { + closed.push(`session:${fresh}`); + }, + }) as unknown as Session; + openSession = () => makeSession(false); + globalThis.fetch = mock( + async () => + new Response("refs", { + headers: { "Content-Type": "application/x-git-upload-pack-advertisement" }, + }), + ) as unknown as typeof fetch; + const response = await browse(new Request("https://viewer.test/_viewer/api?repo=team/demo.git")); + expect(response.status).toBe(200); + expect((await response.json()).history).toEqual([]); + expect(refreshes).toBe(1); + expect(response.headers.get("X-Wal-Calls")).toBe("2"); + expect(response.headers.get("X-Wal-Bytes")).toBe("200"); + expect(closed.sort()).toEqual([ + "recovery:false", + "recovery:true", + "root:false", + "root:true", + "session:false", + "session:true", + ]); +}); diff --git a/examples/git/viewer/src/wal.ts b/examples/git/viewer/src/wal.ts new file mode 100644 index 0000000..9d6797d --- /dev/null +++ b/examples/git/viewer/src/wal.ts @@ -0,0 +1,209 @@ +import type { + Config, + Entry, + Recovery, + Session, + Object as WalObject, +} from "object-log:storage/wal@0.1.0"; +import * as wal from "object-log:storage/wal@0.1.0"; +import { sha1 } from "@noble/hashes/legacy.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { get } from "@spinframework/spin-variables"; +import { unzlibSync } from "fflate"; + +export const variable = (name: string) => get(name) ?? ""; +export const drop = (resource: unknown) => (resource as Disposable)[Symbol.dispose](); +const decode = new TextDecoder("utf-8", { fatal: true }); +export const json = (data: Uint8Array): T => JSON.parse(decode.decode(data)); +export const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +export interface Repository { + log_id: string; + format?: "sha1" | "sha256" | ""; +} +export const canonicalName = (name: string) => (name.endsWith(".git") ? name : `${name}.git`); +export const automaticLogId = (name: string) => + `auto-${hex(sha256(new TextEncoder().encode(name)))}`; +export class MissingObject extends Error {} +interface Item { + ID: string; + Kind: number; + Size: number; + StoredSize: number; + Encoding: string; + Inline?: string; + Delta?: { StoredSize: number }; +} +interface Bucket { + Items?: Item[]; + Prefixes?: string[]; +} +export interface Root { + Validated: boolean; + Format: string; + Head: string; + Refs: Record; + Buckets: string[]; +} + +export function settings(repository: Repository): Config { + return { + endpoint: variable("wal_endpoint"), + bucket: variable("wal_bucket"), + region: variable("wal_region"), + credentialMode: + variable("wal_credential_mode") === "instance-role" ? "instance-role" : "static-credentials", + accessKey: variable("wal_access_key"), + secretKey: variable("wal_secret_key"), + sessionToken: variable("wal_session_token") || undefined, + prefix: variable("wal_prefix"), + logId: repository.log_id, + logLimits: { + maxTailEntries: 1024n, + resolutionWindow: 1024n, + maxInlineOperationBytes: 65536n, + maxInlineResultBytes: 4096n, + maxObjectRefs: 1024n, + maxObjectBytes: 2097152n, + maxCommitBytes: 1048576n, + maxHeadBytes: 262144n, + maxCheckpointBytes: 16777216n, + maxRetentionIds: 1024n, + maxCollectionObjects: BigInt(variable("wal_max_collection_objects")), + maxCollectionPlanBytes: 16777216n, + }, + transportLimits: { maxCalls: 25984n, maxBytes: 26180845568n }, + }; +} + +// Own one exact recovered view; no cache or local repository becomes authority. +export class Catalog { + readonly recovery: Recovery; + readonly root: Root; + private owned: WalObject[] = []; + private buckets = new Map(); + private nodes = new Map(); + + constructor( + session: Session, + format: string | undefined, + private budget: { bytes: number }, + ) { + this.recovery = session.recover(); + try { + let roots: WalObject[] = []; + for (let record = this.recovery.next(); record; record = this.recovery.next()) { + for (const root of roots) drop(root); + roots = record.val.objects; + this.owned = roots; + } + if (roots.length !== 1) throw new Error("Repository has no published root"); + const node = this.node(roots[0]); + this.root = json(node.data); + if ( + !this.root.Validated || + !["sha1", "sha256"].includes(this.root.Format) || + typeof this.root.Head !== "string" || + !/^refs\/heads\/.+$/.test(this.root.Head) || + (format && this.root.Format !== format) || + this.root.Buckets.length !== node.objects.length + ) + throw new Error("Invalid repository catalog"); + this.root.Buckets.forEach((prefix, i) => { + this.buckets.set(prefix, node.objects[i]); + }); + } catch (error) { + this.close(); + throw error; + } + } + + private node(root: WalObject): Entry { + const cached = this.nodes.get(root); + if (cached) return cached; + const entry = this.recovery.readNode(root); + this.owned.push(...entry.objects); + this.budget.bytes += entry.data.length; + if (this.budget.bytes > 64 << 20) throw new Error("Catalog read exceeds 64 MiB"); + this.nodes.set(root, entry); + return entry; + } + + object(id: string, kind: number, maxBytes: number): { size: number; bytes?: Uint8Array } { + const hashBytes = this.root.Format === "sha256" ? 32 : 20; + if (!new RegExp(`^[0-9a-f]{${hashBytes * 2}}$`).test(id)) + throw new Error("Invalid Git object ID"); + let prefix = id.slice(0, 2), + root = this.buckets.get(prefix); + while (root) { + const node = this.node(root), + bucket = json(node.data); + if (bucket.Prefixes?.length) { + prefix = id.slice(0, prefix.length + 1); + if (prefix.length >= id.length || bucket.Prefixes.length !== node.objects.length) break; + root = node.objects[bucket.Prefixes.indexOf(prefix)]; + continue; + } + const items = bucket.Items ?? []; + let child = 0; + for (const item of items) { + const value = item.Inline ? undefined : node.objects[child++]; + if (item.ID !== id) continue; + if (item.Kind !== kind) throw new MissingObject("Git object has a different kind"); + if (item.Encoding !== "zlib" || !Number.isSafeInteger(item.Size) || item.Size < 0) + throw new Error("Invalid Git object metadata"); + if (item.Size > maxBytes) { + if (kind !== 3) throw new Error("Git object exceeds metadata limit"); + return { size: item.Size }; + } + let compressed: Uint8Array; + if (item.Inline) + compressed = Uint8Array.from(atob(item.Inline), (char) => char.charCodeAt(0)); + else { + if (!value) throw new Error("Missing object reference"); + const full = item.Delta?.StoredSize ? this.node(value).objects[0] : value; + const reader = this.recovery.openBytes(full); + try { + if (reader.length() !== BigInt(item.StoredSize) || item.StoredSize > maxBytes + 65536) + throw new Error("Invalid stored object length"); + compressed = new Uint8Array(item.StoredSize); + for (let offset = 0; offset < compressed.length; ) { + const chunk = reader.readAt(BigInt(offset), compressed.length - offset); + if (!chunk.length || chunk.length > compressed.length - offset) + throw new Error("Invalid object read length"); + compressed.set(chunk, offset); + offset += chunk.length; + } + } finally { + drop(reader); + } + } + if (compressed.length !== item.StoredSize || (item.Inline && compressed.length > 512)) + throw new Error("Invalid stored object length"); + const names: Record = { 1: "commit", 2: "tree", 3: "blob" }; + const header = new TextEncoder().encode(`${names[kind]} ${item.Size}\0`); + const expected = header.length + item.Size; + const output = new Uint8Array(expected + 1); + const content = unzlibSync(compressed, { out: output }); + const hash = this.root.Format === "sha256" ? sha256(content) : sha1(content); + if ( + content.length !== expected || + hex(hash) !== id || + !header.every((byte, i) => content[i] === byte) + ) + throw new Error("Git object differs from its catalog"); + return { size: item.Size, bytes: content.subarray(header.length) }; + } + break; + } + throw new MissingObject("Git object not found"); + } + + close() { + for (const resource of this.owned.reverse()) drop(resource); + drop(this.recovery); + } +} + +export const openExisting = wal.openExisting; diff --git a/examples/git/viewer/tsconfig.json b/examples/git/viewer/tsconfig.json new file mode 100644 index 0000000..50f6cb0 --- /dev/null +++ b/examples/git/viewer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "WebWorker"], + "skipLibCheck": true, + "types": ["bun"], + "paths": { + "object-log:storage/wal@0.1.0": ["./build/types/interfaces/object-log-storage-wal.d.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "build.ts", "prepare.ts"] +} diff --git a/scripts/run-git-local.sh b/scripts/run-git-local.sh new file mode 100755 index 0000000..d2781d1 --- /dev/null +++ b/scripts/run-git-local.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +for tool in make spin aws curl python3; do + command -v "$tool" >/dev/null || { echo "Install $tool before running the Git demo." >&2; exit 2; } +done + +# Build before starting disposable services, so a failed build leaves no data. +make -C "$root" git-build + +demo_dir="$(mktemp -d "${TMPDIR:-/tmp}/object-log-git-local.XXXXXX")" +minio_pid="" +spin_pid="" +container="" +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -n "$spin_pid" ]]; then + kill "$spin_pid" 2>/dev/null || true + wait "$spin_pid" 2>/dev/null || true + fi + if [[ -n "$minio_pid" ]]; then + kill "$minio_pid" 2>/dev/null || true + wait "$minio_pid" 2>/dev/null || true + fi + if [[ -n "$container" ]]; then + docker stop "$container" >/dev/null 2>&1 || true + fi + rm -r -- "$demo_dir" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +git_port="${OBJECT_LOG_GIT_LOCAL_PORT:-19100}" +python3 - "$git_port" <<'PY' +import socket +import sys + +with socket.socket() as listener: + try: + port = int(sys.argv[1]) + if not 1 <= port <= 65535: + raise ValueError("expected an integer from 1 to 65535") + listener.bind(("127.0.0.1", port)) + except (OSError, ValueError) as error: + raise SystemExit(f"Git port {sys.argv[1]} is unavailable: {error}") +PY + +access_key="objectlog" +secret_key="local-test-secret" +bucket="wal-proof" +minio_binary="${OBJECT_LOG_MINIO_BINARY:-$(command -v minio || true)}" +if [[ -z "$minio_binary" && -x "$(go env GOPATH)/bin/minio" ]]; then + minio_binary="$(go env GOPATH)/bin/minio" +fi +if [[ -n "$minio_binary" ]]; then + [[ -x "$minio_binary" ]] || { echo "MinIO binary is not executable: $minio_binary" >&2; exit 2; } + minio_port="$(python3 - <<'PY' +import socket + +with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + print(listener.getsockname()[1]) +PY +)" + endpoint="http://127.0.0.1:$minio_port" + MINIO_ROOT_USER="$access_key" MINIO_ROOT_PASSWORD="$secret_key" MINIO_BROWSER=off \ + "$minio_binary" server "$demo_dir/minio" --address "127.0.0.1:$minio_port" \ + >"$demo_dir/minio.log" 2>&1 & + minio_pid=$! +else + command -v docker >/dev/null || { echo "Install MinIO or Docker before running the Git demo." >&2; exit 2; } + docker info >/dev/null + container="object-log-git-local-$$" + docker run --detach --rm --name "$container" --publish 127.0.0.1::9000 \ + --env "MINIO_ROOT_USER=$access_key" --env "MINIO_ROOT_PASSWORD=$secret_key" \ + 'minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e' \ + server /data >/dev/null + endpoint="http://$(docker port "$container" 9000/tcp)" +fi + +ready=0 +for _ in {1..60}; do + if curl --connect-timeout 1 --max-time 2 --fail --silent "$endpoint/minio/health/ready" >/dev/null; then + ready=1 + break + fi + if [[ -n "$minio_pid" ]] && ! kill -0 "$minio_pid" 2>/dev/null; then break; fi + sleep 1 +done +if [[ "$ready" != 1 ]]; then + if [[ -n "$minio_pid" ]]; then cat "$demo_dir/minio.log" >&2; else docker logs "$container" >&2; fi + echo "MinIO did not start." >&2 + exit 1 +fi + +AWS_ACCESS_KEY_ID="$access_key" AWS_SECRET_ACCESS_KEY="$secret_key" \ + AWS_SESSION_TOKEN="" AWS_SECURITY_TOKEN="" \ + AWS_DEFAULT_REGION=us-east-1 aws --no-cli-pager --endpoint-url "$endpoint" \ + s3api create-bucket --bucket "$bucket" >/dev/null + +cat >"$demo_dir/variables.toml" <"$demo_dir/spin.log" 2>&1 & +spin_pid=$! + +validated=0 +for _ in {1..60}; do + if curl --connect-timeout 1 --max-time 5 --fail --silent \ + --user git:local-git-password --request POST \ + "http://127.0.0.1:$git_port/_validate_backend" >/dev/null; then + validated=1 + break + fi + if ! kill -0 "$spin_pid" 2>/dev/null; then break; fi + sleep 1 +done +if [[ "$validated" != 1 ]]; then + cat "$demo_dir/spin.log" >&2 + echo "Spin did not validate its MinIO backend." >&2 + exit 1 +fi + +cat < README.md + git add README.md && git commit -m 'Initial commit' + git push http://127.0.0.1:$git_port/team/demo.git main + +When prompted, use username git and password local-git-password. +Press Ctrl-C here to stop Spin and MinIO and remove their disposable data. +EOF +wait "$spin_pid"