Compare commits

..
Author SHA1 Message Date
Kevin CodexandOpenClaude 7175409d02 fix(security): reject path traversal in repo_store local_path
Closes CodeQL alert #4 (Uncontrolled data used in path expression).

Both `owner_did` and `repo_name` come from URL parameters and were used
unsanitized to build a filesystem path, so a request like
`/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos
directory.

Fix:

- New `validate_owner_did` and `validate_repo_name` enforce a strict
  allowlist before path construction (alphanumeric + `: . _ -` for DIDs;
  alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading
  `.` or `-`, slashes, backslashes, null bytes, overlong inputs).
- `RepoStore::local_path` now returns `Result<…>` and the 5 callers
  propagate the error.
- Defence in depth: even after the allowlist passes, the joined path is
  checked to still be rooted at `repos_dir`.
- 17 unit tests cover normal DIDs/repo names plus every malicious shape
  (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with
  dots).

Co-Authored-By: OpenClaude <[email protected]>
2026-05-04 22:15:17 +08:00
143 changed files with 2659 additions and 16944 deletions

No files matched your search

+2 -16
View File
@@ -63,27 +63,13 @@ POSTGRES_PASSWORD=changeme
# ── P2P networking ────────────────────────────────────────────────────────
GITLAWB_P2P_PORT=7546
# Comma-separated HTTP peer URLs to announce to on startup.
# The binary also ships with bootstrap-peers.json; set
# GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true for isolated dev/test networks.
# Comma-separated multiaddrs of bootstrap peers
GITLAWB_BOOTSTRAP_PEERS=
GITLAWB_BOOTSTRAP_DISABLE_SEEDS=false
# Require signatures on peer announce/sync write routes. Keep false during
# rolling upgrades so existing live nodes can still communicate.
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
# Comma-separated libp2p multiaddrs.
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
GITLAWB_P2P_BOOTSTRAP=
# ── Access control ────────────────────────────────────────────────────────
# Reserved for private-read mode. Public/private repo read enforcement is not
# wired in the current live release; do not rely on this for private repositories.
# Set to false to require auth on all read endpoints
GITLAWB_PUBLIC_READ=true
# Maximum git smart-HTTP pack request size, in bytes.
GITLAWB_MAX_PACK_BYTES=2147483648
# ── Sync ─────────────────────────────────────────────────────────────────
# Enable automatic background sync from known peers
GITLAWB_AUTO_SYNC=false
-5
View File
@@ -1,7 +1,6 @@
/target
**/*.rs.bk
Cargo.lock.bak
dist/
# Local node data
/data
@@ -22,7 +21,3 @@ keys/
# Logs
*.log
.openclaude-profile.json
# Local planning / scratch docs (never commit)
docs/superpowers/
+1 -1
View File
@@ -58,7 +58,7 @@ Smart contracts live in a separate repo: [github.com/Gitlawb/contracts](https://
## Development environment
**Requirements:**
- Rust stable (≥ 1.91) — install via [rustup](https://rustup.rs)
- Rust stable (≥ 1.85) — install via [rustup](https://rustup.rs)
- PostgreSQL — required for the node. Use the bundled `docker-compose.yml` for local dev.
- Docker (optional, for full-stack local testing)
Generated
+655 -153
View File
File diff suppressed because it is too large. Load diff
+2 -4
View File
@@ -5,11 +5,10 @@ members = [
"crates/gitlawb-node",
"crates/gl",
"crates/git-remote-gitlawb",
"crates/gitlawb-attest",
]
[workspace.package]
version = "0.3.9"
version = "0.3.8"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["gitlawb contributors"]
@@ -21,13 +20,12 @@ tokio = { version = "1", features = ["full"] }
# serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_jcs = "0.2"
# errors
thiserror = "2"
anyhow = "1"
# crypto
ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"] }
rand = "0.8.6"
rand = "0.8"
sha2 = "0.10"
# encoding
hex = "0.4.3"
+1 -2
View File
@@ -28,7 +28,6 @@ RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src crate
# cargo can keep the dummy `fn main() {}` binaries from the cache layer above
# and the runtime container exits immediately with code 0.
COPY crates/ crates/
COPY bootstrap-peers.json ./
RUN find crates -name "*.rs" -exec touch {} + && \
rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \
rm -rf target/release/.fingerprint/gitlawb-node-* \
@@ -65,7 +64,7 @@ ENV GITLAWB_REPOS_DIR=/data/repos \
GITLAWB_PORT=7545 \
GITLAWB_P2P_PORT=7546
EXPOSE 7545 7546/udp
EXPOSE 7545 7546
VOLUME ["/data"]
+54 -387
View File
@@ -1,273 +1,49 @@
# Gitlawb Node
# gitlawb node
**Decentralized git infrastructure for developers, AI agents, and app delivery.**
**Decentralized git for AI agents and developers.** Run your own node, host repos under your own DID, and connect to the gitlawb network.
Gitlawb Node is the open-source node software behind the Gitlawb network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
This repository contains the open-source node software:
Gitlawb is not trying to be only “another git host.” The long-term direction is:
```txt
Decentralized GitHub
+ signed agent-native workflows
+ resilient repo replication
+ CDN-style app/code delivery
```
The mission is simple: once code is pushed to the network, it should not disappear because one server went down.
- `gitlawb-node` — Axum HTTP server, git smart-HTTP, libp2p gossip, optional S3-compatible storage
- `gl` — CLI for identity, repos, PRs, MCP server
- `git-remote-gitlawb` — `git remote helper` for `gitlawb://` URLs
- `gitlawb-core` — shared crypto primitives (DID, CID, HTTP signatures, UCAN)
---
## What is in this repository?
## Quickstart
This is a Rust workspace with four crates:
| Crate | Purpose |
|---|---|
| `gitlawb-node` | The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
| `gl` | The Gitlawb CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
| `git-remote-gitlawb` | Git remote helper for `gitlawb://` URLs, so normal `git clone`, `git fetch`, and `git push` can talk to Gitlawb nodes. |
| `gitlawb-core` | Shared primitives: Ed25519 identities, `did:key`, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
---
## Why Gitlawb Nodes?
Most git hosting today depends on a small number of centralized platforms. Gitlawb Nodes are designed for a different model:
- **Own your identity** — every user, agent, and node is an Ed25519 keypair represented as `did:key:z6Mk...`.
- **Signed writes by default** — write requests use RFC 9421 HTTP Signatures instead of passwords.
- **Git-native transport** — repositories are still real git repositories served over smart HTTP.
- **Agent-native workflows** — the `gl` CLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents.
- **Peer-aware delivery** — nodes can announce, discover, gossip, and sync with each other.
- **App CDN direction** — the network can evolve from decentralized code storage into code + asset + app delivery.
---
## Current status
Gitlawb Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Good today:
- Local or Docker node startup.
- Postgres-backed repo metadata.
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
- DID identities.
- `gl` CLI workflows.
- libp2p peer discovery/gossip foundation.
- Optional Tigris/S3 storage.
- Optional IPFS/Pinata and Arweave/Irys hooks.
- Optional Base node-operator staking/heartbeat hooks.
Known limitations:
- Private repository read enforcement is not wired yet. Treat public nodes as public infrastructure unless you restrict access at your proxy/firewall.
- UCAN chain validation and revocation are not complete.
- Repository write authorization is not capability-complete yet; HTTP signatures prove identity, not full authorization policy.
- Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations need mutation-aware auth before becoming a public write surface.
See:
- [`SECURITY.md`](SECURITY.md)
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
---
## Quickstart: run a local node
The fastest path is Docker Compose. It starts a node and Postgres.
The fastest way to run a node is via Docker. You'll get a node + Postgres locally with one command.
```bash
git clone https://github.com/Gitlawb/node.git
git clone https://github.com/gitlawb/node.git
cd node
cp .env.example .env
docker compose up -d
```
Your local node will serve:
Your node is now serving:
| Service | Default |
|---|---|
| HTTP API + git smart-HTTP | `http://localhost:7545` |
| libp2p QUIC/UDP | `7546` |
| Postgres | compose-managed |
- HTTP + git smart-HTTP on `:7545`
- libp2p gossip on `:7546`
Verify:
```bash
curl http://localhost:7545/health
curl http://localhost:7545/api/v1/stats
```
Expected health response:
```json
{ "status": "ok" }
```
Stop it:
```bash
docker compose down
```
---
## Install the CLI
## How it works
Use the installer once releases are available for your platform:
- **Identity** — every actor (human or agent) is an Ed25519 keypair → `did:key:z6Mk...`. No accounts, no passwords.
- **Auth** — every write is signed via HTTP Signatures (RFC 9421).
- **Delegation** — UCAN capability tokens.
- **Storage** — local disk + optional S3-compatible bucket (Tigris, MinIO, AWS S3).
- **Networking** — libp2p Gossipsub for ref-update events, Kademlia DHT for node discovery.
```bash
curl -fsSL https://gitlawb.com/install.sh | sh
```
Or build from source:
```bash
cargo build --release -p gl -p git-remote-gitlawb -p gitlawb-node
```
Put these binaries on your `PATH`:
```txt
target/release/gl
target/release/git-remote-gitlawb
target/release/gitlawb-node
```
Check your setup:
```bash
gl doctor
```
---
## First repo flow
Create an identity:
```bash
gl identity new
gl identity show
```
Register against your local node:
```bash
gl register --node http://localhost:7545
```
Create a repo:
```bash
gl repo create my-repo --description "My first Gitlawb repo" --node http://localhost:7545
```
Use the git remote helper:
```bash
export GITLAWB_NODE=http://localhost:7545
git clone gitlawb://did:key:z6Mk.../my-repo
```
For public-network use, make sure `GITLAWB_NODE` points to the node you want. The helper defaults to localhost for local development.
---
## Architecture
```txt
┌──────────────────────────┐
│ gl CLI / git / AI agents │
└────────────┬─────────────┘
│ signed HTTP writes / git smart-HTTP
↓
┌──────────────────────────┐
│ gitlawb-node │
│ Axum API + git routes │
└────────────┬─────────────┘
│
┌────────┴────────┐
↓ ↓
Postgres Bare git repos
metadata local disk / optional S3
│ │
└────────┬────────┘
↓
libp2p peers
gossip + discovery + sync
↓
optional IPFS / Arweave / Base PoS
```
### Core concepts
| Concept | Meaning |
|---|---|
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| UCAN | Delegation token for future capability-based workflows. |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
---
## API surface
The node exposes both git smart-HTTP routes and JSON APIs.
Common public read routes:
```txt
GET /health
GET /
GET /api/v1/stats
GET /api/v1/contracts
GET /api/v1/repos
GET /api/v1/repos/{owner}/{repo}
GET /api/v1/repos/{owner}/{repo}/tree
GET /api/v1/repos/{owner}/{repo}/blob/{path}
GET /api/v1/repos/{owner}/{repo}/issues
GET /api/v1/repos/{owner}/{repo}/pulls
GET /api/v1/peers
GET /{owner}/{repo}/info/refs
POST /{owner}/{repo}/git-upload-pack
```
Signed write routes include:
```txt
POST /api/v1/repos
POST /api/register
POST /api/v1/repos/{owner}/{repo}/fork
POST /api/v1/repos/{owner}/{repo}/issues
POST /api/v1/repos/{owner}/{repo}/pulls
POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge
POST /api/v1/repos/{owner}/{repo}/hooks
POST /api/v1/bounties/{id}/...
POST /{owner}/{repo}/git-receive-pack
```
Peer write routes support staged rollout:
```txt
POST /api/v1/peers/announce
POST /api/v1/sync/notify
POST /api/v1/sync/trigger
```
When `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false`, unsigned legacy peers are accepted, but signed requests are verified when signature headers are present. Once all live peers upgrade, operators can set:
```bash
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
```
The node is **stateless beyond Postgres + the repos directory.** You can scale horizontally by pointing multiple nodes at a shared S3 bucket and database.
---
@@ -275,185 +51,76 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
All configuration is via environment variables. See [`.env.example`](.env.example) for the full reference.
Minimum required for a persistent node:
The minimum required:
```env
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
```
- `DATABASE_URL` — Postgres connection string
Important node settings:
Everything else has sensible defaults. Optional features:
| Variable | Purpose |
|---|---|
| `GITLAWB_HOST` / `GITLAWB_PORT` | HTTP bind address and port. |
| `GITLAWB_REPOS_DIR` | Local bare repo storage directory. |
| `GITLAWB_PUBLIC_URL` | Public HTTP URL announced to peers. |
| `GITLAWB_P2P_PORT` | libp2p QUIC/UDP port. Use `0` to disable. |
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
| `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. |
| `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. |
| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. |
- `GITLAWB_TIGRIS_BUCKET` (+ AWS credentials) — S3-compatible shared storage
- `GITLAWB_PINATA_JWT` — IPFS warm-storage pinning
- `GITLAWB_IRYS_URL` — Arweave permanent anchoring
- `GITLAWB_BOOTSTRAP_PEERS` — comma-separated peer URLs to announce to on startup
- `GITLAWB_P2P_BOOTSTRAP` — comma-separated libp2p multiaddrs
Production note: change the default Postgres password before exposing a node publicly.
### On-chain Proof-of-Stake (advanced, optional)
The node supports an optional on-chain PoS layer where operators register their DID, stake $GITLAWB on Base L2, and receive a share of protocol fees. Smart contracts are in a separate repository at [github.com/gitlawb/contracts](https://github.com/gitlawb/contracts).
PoS is **disabled by default** and currently paused pending external audit. To enable, set `GITLAWB_CONTRACT_NODE_STAKING` and `GITLAWB_OPERATOR_PRIVATE_KEY`. See [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md) for details.
---
## Optional node staking
## Build from source
Gitlawb Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake `$GITLAWB`, and post heartbeats.
PoS is disabled unless these are configured:
```env
GITLAWB_CONTRACT_NODE_STAKING=0x...
GITLAWB_OPERATOR_PRIVATE_KEY=0x...
GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org
```
Recommended for operators:
```env
GITLAWB_OPERATOR_STRICT_MODE=true
GITLAWB_HEARTBEAT_INTERVAL_HOURS=20
```
Read:
- [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md)
- [`docs/ECONOMICS.md`](docs/ECONOMICS.md)
Use a dedicated low-balance operator wallet. Do not use a treasury wallet as the heartbeat key.
---
## Building from source
Requires Rust 1.91+.
Requires Rust 1.85+ and a Postgres instance.
```bash
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb
```
Run tests:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
Run the node from source:
```bash
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb \
cargo run -p gitlawb-node --release
```
Binaries are placed in `target/release/`. The `gl` and `git-remote-gitlawb` binaries should be on your `$PATH` for client use.
---
## macOS menu bar app
A native Swift/AppKit menu bar app is included for managing a local Docker Compose stack without living in the terminal.
Requirements:
- macOS 26+
- Xcode Command Line Tools
- Docker Desktop, OrbStack, or Colima
Build:
## CLI usage
```bash
./scripts/build-macos-app.sh
gl identity new
gl register --node http://localhost:7545
gl repo create my-repo --description "..."
git clone gitlawb://did:key:z6Mk.../my-repo
```
Output:
```txt
dist/Gitlawb Node.app
dist/Gitlawb Node.dmg
```
Features:
- Start/stop local node stack.
- Status indicator.
- Settings GUI.
- Auto-start on login.
- Docker runtime detection.
Unsigned local build:
```bash
xattr -cr "dist/Gitlawb Node.app"
```
Full CLI reference: `gl --help`.
---
## Roadmap
## Architecture
The current maintainer focus is live-network stability first.
Short-term priorities:
1. Keep CI green: fmt, clippy, tests, release build.
2. Add Docker and installer smoke tests.
3. Improve operator docs and `gl doctor` checks.
4. Harden peer writes and publish the signed-peer rollout plan.
5. Implement repo write authorization: owner checks, protected branches, and UCAN capability checks.
6. Implement private-read enforcement or remove private repo affordances until it exists.
7. Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
Product direction:
1. Reliable repo replication.
2. Health-aware peer syncing.
3. CDN-style clone/fetch routing to healthy replicas.
4. App asset/build delivery from nodes.
5. Operator dashboard and desktop UX.
Read the maintainer roadmap:
```txt
docs/MAINTAINER-ROADMAP.md
```
crates/
├── gitlawb-core/ crypto primitives (DID, CID, HTTP sigs, UCAN)
├── gitlawb-node/ node daemon (Axum + git smart-HTTP)
├── gl/ CLI
└── git-remote-gitlawb/ git remote helper
```
---
## Contributing
Start here:
- [`CONTRIBUTING.md`](CONTRIBUTING.md)
- [`docs/MAINTAINER-ROADMAP.md`](docs/MAINTAINER-ROADMAP.md)
- [`docs/OSS-READINESS-AUDIT.md`](docs/OSS-READINESS-AUDIT.md)
- [`SECURITY.md`](SECURITY.md)
Good first contribution areas:
- docs and install polish
- Docker smoke tests
- CLI error messages
- `gl doctor` checks
- operator dashboard UX
- test coverage for peer sync and signed writes
Security issues should follow [`SECURITY.md`](SECURITY.md).
---
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security issues: see [`SECURITY.md`](SECURITY.md).
## License
Licensed under either of:
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual licensed as above, without any additional terms or conditions.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
+11 -22
View File
@@ -16,7 +16,7 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
## Current Security Architecture
### Current live security controls
### What is production-ready (v0.1)
**Ed25519 identity and HTTP Signatures**
- Every write operation is signed with RFC 9421 HTTP Signatures
@@ -42,46 +42,35 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d
## Dependency Vulnerability Status
| Area | Status |
|------|--------|
| Dependabot alerts | Current open Rust alerts were addressed by updating vulnerable dependencies, removing libp2p mDNS, and moving P2P transport from TCP/Yamux to QUIC/UDP. |
| Crate | Advisory | Severity | Status |
|-------|----------|----------|--------|
| `rsa 0.9.x` | RUSTSEC-2023-0071 Marvin Attack | Medium | Unexploitable — bundled via `sqlx-mysql` internals; gitlawb uses PostgreSQL only, MySQL auth code is never executed. No upstream fix available. |
| `lru 0.12.x` | RUSTSEC-2026-0002 unsound IterMut | Warning | In `libp2p-swarm`; the specific unsafe code path is internal to libp2p and not reachable through gitlawb's usage. No upstream fix yet. |
---
## Known Limitations (Planned for v0.2)
These are **documented, accepted limitations** for the current live release and should be prioritized without breaking existing nodes during rolling upgrades.
These are **documented, accepted limitations** for the current testnet release. They will be addressed before mainnet.
### UCAN chain validation
- The auth middleware verifies HTTP Signatures and token structure, but does not yet walk the full UCAN delegation chain.
- **Impact:** A node cannot yet enforce fine-grained capability delegation. Currently, any registered agent with a valid HTTP Signature can push.
- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization.
- **Mitigation:** Nodes can set `GITLAWB_PUBLIC_READ=false` to restrict access. The v0.1 trust score system provides soft rate limiting.
- **Fix target:** v0.2
### UCAN revocation
- Issued UCAN tokens cannot be revoked before expiry.
- **Impact:** If a keypair is compromised, the attacker retains access until the UCAN expires (default: 30 days).
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Until revocation/blocklisting is implemented, operators should remove compromised DIDs directly from their local database.
- **Mitigation:** Regenerate your identity (`gl identity new --force`) and re-register to issue a new UCAN. Nodes will reject the old DID on sight if manually blocklisted.
- **Fix target:** v0.2
### git-receive-pack authentication
- The `git-receive-pack` endpoint enforces HTTP Signature auth. Plain Git smart-HTTP clients do not generate those headers, so the `git-remote-gitlawb` helper is required for pushes.
- **Impact:** Direct HTTP pushes without RFC 9421 headers are rejected; users need `gitlawb://` remotes or equivalent signed clients.
- **Mitigation:** Use `gitlawb://` remote URLs and keep `git-remote-gitlawb` on the user's `PATH`.
- The `git-receive-pack` endpoint enforces HTTP Signature auth via the `git-remote-gitlawb` remote helper. Direct HTTP push without the helper is currently unauthenticated on v0.1 nodes.
- **Impact:** A node operator accepting direct HTTP git pushes (not via `gitlawb://`) cannot verify the pusher's identity.
- **Mitigation:** Use `gitlawb://` remote URLs. Do not expose `POST /*/git-receive-pack` to untrusted networks.
- **Fix target:** v0.2
### Private repository reads
- Repository records have an `is_public` field and the node exposes `GITLAWB_PUBLIC_READ`, but per-repository private-read enforcement is not wired in the current live release.
- **Impact:** Do not store private repositories or secrets on public nodes.
- **Mitigation:** Run isolated nodes for non-public data and restrict network access at the reverse proxy or firewall layer.
- **Fix target:** v0.2
### Peer route hardening rollout
- Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present.
- **Impact:** Unsigned peer writes are still accepted by default so existing live nodes can keep communicating during rolling upgrades.
- **Mitigation:** After all active peers run signed-node builds, operators can set `GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true`.
- **Fix target:** staged rollout
---
## Supported Versions
-23
View File
@@ -1,23 +0,0 @@
{
"$comment": "Canonical seed list for the Gitlawb network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
"version": 1,
"updated": "2026-05-16",
"peers": [
{
"name": "gitlawb",
"operator": "Gitlawb (Kevin)",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-04-29"
},
{
"name": "rapybus-gitlawb",
"operator": "RapyBus S.A.C.",
"did": "did:key:z6MktERLyhFpjUyN7dnkf7CYY5RXKJ6BTZiKohoi37YVBS7R",
"http_url": "https://rapybus-gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-05-16"
}
]
}
+26 -181
View File
@@ -16,39 +16,23 @@
//! connect git-upload-pack → GET /info/refs | POST /git-upload-pack
//! connect git-receive-pack → GET /info/refs | POST /git-receive-pack (+ auth header)
use anyhow::{bail, Context, Result};
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use std::io::{self, BufRead, Read, Write};
use anyhow::{bail, Context, Result};
use gitlawb_core::identity::Keypair;
use gitlawb_core::http_sig::sign_request;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
// Handle informational flags before anything else. Git always invokes a
// remote helper as `git-remote-gitlawb <remote-name> <url>`, so these flags
// only ever appear on direct user or release-smoke-test invocations. Print
// to stdout and exit before tracing init so the output stays clean.
match classify_args(&args) {
Invocation::Version => {
println!("{}", version_line());
return Ok(());
}
Invocation::Help => {
print!("{}", help_text());
return Ok(());
}
Invocation::Helper => {}
}
// All logging goes to stderr so it doesn't corrupt the git protocol on stdout
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(std::env::var("GITLAWB_LOG").unwrap_or_else(|_| "warn".to_string()))
.with_env_filter(
std::env::var("GITLAWB_LOG").unwrap_or_else(|_| "warn".to_string()),
)
.init();
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: git-remote-gitlawb <remote-name> <url>");
eprintln!("try 'git-remote-gitlawb --help' for more information");
std::process::exit(1);
}
@@ -58,8 +42,8 @@ fn main() -> Result<()> {
let (_, short_owner, repo_name) = parse_gitlawb_url(url)?;
// v0.1: default to localhost. Override with GITLAWB_NODE env var.
let node_base =
std::env::var("GITLAWB_NODE").unwrap_or_else(|_| "http://127.0.0.1:7545".to_string());
let node_base = std::env::var("GITLAWB_NODE")
.unwrap_or_else(|_| "http://127.0.0.1:7545".to_string());
let repo_base = format!("{}/{}/{}", node_base, short_owner, repo_name);
tracing::debug!("repo_base: {repo_base}");
@@ -69,62 +53,6 @@ fn main() -> Result<()> {
run_helper(&repo_base, keypair.as_ref())
}
// ── CLI argument handling ──────────────────────────────────────────────────────
/// How the binary was invoked, derived from its CLI arguments.
#[derive(Debug, PartialEq, Eq)]
enum Invocation {
/// `--version` / `-V`: print the version line and exit.
Version,
/// `--help` / `-h`: print usage and exit.
Help,
/// Normal git remote-helper invocation: `<remote-name> <url>`.
Helper,
}
/// Classify the process arguments.
///
/// Git always calls a remote helper as `git-remote-gitlawb <remote-name> <url>`,
/// so the informational flags are only recognized as the first argument; this
/// keeps the remote-helper protocol path untouched for git's own invocations.
fn classify_args(args: &[String]) -> Invocation {
match args.get(1).map(String::as_str) {
Some("--version") | Some("-V") => Invocation::Version,
Some("--help") | Some("-h") => Invocation::Help,
_ => Invocation::Helper,
}
}
/// The version line, matching the `<bin> <version>` format that the clap-based
/// `gl` and `gitlawb-node` binaries emit, so release smoke tests can treat all
/// three binaries uniformly.
fn version_line() -> String {
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
/// Usage text for `--help`.
fn help_text() -> String {
format!(
"{}\n\
\n\
Git remote helper for gitlawb:// URLs. Git invokes this automatically\n\
when it encounters a gitlawb:// remote; you normally do not run it directly.\n\
\n\
USAGE:\n\
\x20 git clone gitlawb://did:key:z6Mk.../<repo>\n\
\n\
ENVIRONMENT:\n\
\x20 GITLAWB_NODE Node base URL (default: http://127.0.0.1:7545)\n\
\x20 GITLAWB_KEY Identity PEM path for signed pushes (default: ~/.gitlawb/identity.pem)\n\
\x20 GITLAWB_LOG Log filter (default: warn)\n\
\n\
FLAGS:\n\
\x20 -V, --version Print version and exit\n\
\x20 -h, --help Print this help and exit\n",
version_line()
)
}
// ── Remote helper protocol loop ───────────────────────────────────────────────
fn run_helper(repo_base: &str, keypair: Option<&Keypair>) -> Result<()> {
@@ -134,9 +62,7 @@ fn run_helper(repo_base: &str, keypair: Option<&Keypair>) -> Result<()> {
loop {
let mut line = String::new();
let n = stdin_buf
.read_line(&mut line)
.context("reading command from git")?;
let n = stdin_buf.read_line(&mut line).context("reading command from git")?;
if n == 0 {
break; // EOF
}
@@ -223,10 +149,7 @@ fn handle_connect(
// But git's `connect` protocol expects raw git-upload-pack output (no HTTP wrapper).
// Strip the service-line pkt-line + flush before forwarding.
let advertisement = strip_service_announcement(&refs_bytes);
tracing::debug!(
"ref advertisement: {} bytes (stripped)",
advertisement.len()
);
tracing::debug!("ref advertisement: {} bytes (stripped)", advertisement.len());
let mut stdout = io::stdout();
stdout.write_all(advertisement)?;
@@ -249,9 +172,7 @@ fn handle_connect(
read_upload_pack_request(stdin).context("reading upload-pack request")?
} else {
let mut buf = Vec::new();
stdin
.read_to_end(&mut buf)
.context("reading receive-pack request")?;
stdin.read_to_end(&mut buf).context("reading receive-pack request")?;
buf
};
@@ -271,7 +192,10 @@ fn handle_connect(
let mut req = client
.post(&post_url)
.header("Content-Type", format!("application/x-{}-request", service))
.header(
"Content-Type",
format!("application/x-{}-request", service),
)
.header("User-Agent", "git/2.0 git-remote-gitlawb/0.1.0")
.body(request_body.clone());
@@ -291,7 +215,9 @@ fn handle_connect(
}
}
let pack_resp = req.send().with_context(|| format!("POST {post_url}"))?;
let pack_resp = req
.send()
.with_context(|| format!("POST {post_url}"))?;
if !pack_resp.status().is_success() {
bail!("POST /{} returned {}", service, pack_resp.status());
@@ -370,9 +296,7 @@ fn read_upload_pack_request(stdin: &mut io::BufReader<io::Stdin>) -> Result<Vec<
let data_len = pkt_len - 4;
let mut data = vec![0u8; data_len];
stdin
.read_exact(&mut data)
.context("reading pkt-line data")?;
stdin.read_exact(&mut data).context("reading pkt-line data")?;
buf.extend_from_slice(&data);
// "done\n" signals the end of the want/have negotiation
@@ -456,8 +380,8 @@ fn load_keypair() -> Option<Keypair> {
}
fn resolve_key_path() -> std::path::PathBuf {
let path_str =
std::env::var("GITLAWB_KEY").unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
let path_str = std::env::var("GITLAWB_KEY")
.unwrap_or_else(|_| "~/.gitlawb/identity.pem".to_string());
if let Some(stripped) = path_str.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
@@ -475,7 +399,8 @@ mod tests {
#[test]
fn parse_standard_url() {
let (did, owner, repo) = parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo").unwrap();
let (did, owner, repo) =
parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo").unwrap();
assert_eq!(did, "did:key:z6MkFoo123");
assert_eq!(owner, "z6MkFoo123");
assert_eq!(repo, "my-repo");
@@ -483,7 +408,8 @@ mod tests {
#[test]
fn parse_url_strips_dot_git() {
let (_, _, repo) = parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo.git").unwrap();
let (_, _, repo) =
parse_gitlawb_url("gitlawb://did:key:z6MkFoo123/my-repo.git").unwrap();
assert_eq!(repo, "my-repo");
}
@@ -519,85 +445,4 @@ mod tests {
"/z6Mk/myrepo/git-receive-pack"
);
}
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn classify_version_flags() {
assert_eq!(
classify_args(&args(&["git-remote-gitlawb", "--version"])),
Invocation::Version
);
assert_eq!(
classify_args(&args(&["git-remote-gitlawb", "-V"])),
Invocation::Version
);
}
#[test]
fn classify_help_flags() {
assert_eq!(
classify_args(&args(&["git-remote-gitlawb", "--help"])),
Invocation::Help
);
assert_eq!(
classify_args(&args(&["git-remote-gitlawb", "-h"])),
Invocation::Help
);
}
#[test]
fn classify_normal_helper_invocation() {
// How git actually calls us: `<remote-name> <url>`.
assert_eq!(
classify_args(&args(&[
"git-remote-gitlawb",
"origin",
"gitlawb://did:key:z6MkFoo123/my-repo",
])),
Invocation::Helper
);
}
#[test]
fn classify_no_args_is_helper() {
// No flag → falls through to the helper path, which reports its own
// usage error. `--version`/`--help` must not be inferred from emptiness.
assert_eq!(
classify_args(&args(&["git-remote-gitlawb"])),
Invocation::Helper
);
}
#[test]
fn classify_flag_only_in_first_position() {
// A remote literally named "--version" must not be treated as the flag.
assert_eq!(
classify_args(&args(&["git-remote-gitlawb", "origin", "--version",])),
Invocation::Helper
);
}
#[test]
fn version_line_matches_package_metadata() {
let line = version_line();
assert_eq!(
line,
format!("git-remote-gitlawb {}", env!("CARGO_PKG_VERSION"))
);
// Single line, no trailing newline (println! adds the newline).
assert!(!line.contains('\n'));
}
#[test]
fn help_text_includes_version_and_flags() {
let help = help_text();
assert!(help.starts_with(&version_line()));
assert!(help.contains("--version"));
assert!(help.contains("--help"));
assert!(help.contains("GITLAWB_NODE"));
assert!(help.ends_with('\n'));
}
}
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "gitlawb-attest"
description = "External Attestation v1: pluggable provenance attachments for gitlawb ref-update certs"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
serde_jcs = { workspace = true }
thiserror = { workspace = true }
ed25519-dalek = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
multibase = { workspace = true }
[dev-dependencies]
gitlawb-core = { path = "../gitlawb-core" }
rand = { workspace = true }
-557
View File
@@ -1,557 +0,0 @@
//! One typed, signed, cert-bound provenance blob.
//!
//! Fields: `type` (discriminator), `payload` (opaque, type-specific JSON),
//! `cert_hash` (binds to one ref-update cert), `signer` (`did:key`), and
//! `sig` (base64url-no-pad ed25519).
//!
//! The signature covers the domain-separated input
//! `b"gitlawb-attest-sig/v1\n"` followed by JCS-encoded `{type, payload,
//! cert_hash}` per RFC 8785. The domain tag is in the byte stream rather
//! than the canonical JSON so the wire shape stays additive: an `Attestation`
//! deserialized today and one constructed by a future `v2` signer differ only
//! by which tag the verifier prepends.
//!
//! Type discriminators are slash-separated namespace + version strings
//! (`covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`); verifiers register
//! by exact match.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64U, Engine};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::error::{Error, Result};
/// Domain-separation tag prepended to the JCS signing input. Bumped to
/// `v2` if the signing-input shape changes.
const SIGNING_DOMAIN: &[u8] = b"gitlawb-attest-sig/v1\n";
/// Multicodec varint prefix for Ed25519 public keys (0xed 0x01).
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
/// A signed provenance blob attached to a ref-update cert.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Attestation {
/// Type discriminator, e.g. `covenant/exec/v1`.
#[serde(rename = "type")]
pub type_: String,
/// Type-specific payload. The verifier reparses into its concrete shape.
pub payload: serde_json::Value,
/// Lowercase SHA-256 hex of the cert body. Binds this attestation to one
/// cert. The lowercase form is canonical because the JCS signing input
/// includes this field verbatim — accepting mixed case on the wire would
/// require both signer and verifier to normalize before computing the
/// JCS bytes, an easy spec rule to miss across language ports.
pub cert_hash: String,
/// `did:key` of the signer; the verifying key is recoverable from it.
pub signer: String,
/// base64url-no-pad ed25519 signature over the JCS signing input.
pub sig: String,
}
/// Type-specific payload. Implement on a struct that is `Serialize +
/// DeserializeOwned` to participate.
pub trait AttestationPayload: Serialize + DeserializeOwned + Send + Sync {
/// The discriminator string written on the wire.
fn payload_type() -> &'static str;
}
#[derive(Serialize)]
struct SigningInput<'a> {
#[serde(rename = "type")]
type_: &'a str,
payload: &'a serde_json::Value,
cert_hash: &'a str,
}
impl Attestation {
/// Sign a fresh attestation. `cert_hash_bytes` comes from
/// [`crate::cert::cert_hash`].
pub fn sign<P: AttestationPayload>(
signing_key: &SigningKey,
payload: P,
cert_hash_bytes: [u8; 32],
) -> Result<Self> {
let type_ = P::payload_type().to_string();
validate_type(&type_)?;
let payload_value = serde_json::to_value(payload)?;
let cert_hash_hex = hex::encode(cert_hash_bytes);
let bytes = canonical_signing_bytes(&type_, &payload_value, &cert_hash_hex)?;
let sig: Signature = signing_key.sign(&bytes);
Ok(Self {
type_,
payload: payload_value,
cert_hash: cert_hash_hex,
signer: did_key_from_verifying_key(&signing_key.verifying_key()),
sig: B64U.encode(sig.to_bytes()),
})
}
/// Verify the signature and check that `cert_hash` matches
/// `expected_cert_hash`. Returns the recovered verifying key so the caller
/// can check it against an allowlist.
pub fn verify_signature(&self, expected_cert_hash: [u8; 32]) -> Result<VerifyingKey> {
let expected_hex = hex::encode(expected_cert_hash);
if self.cert_hash != expected_hex {
return Err(Error::CertHashMismatch);
}
validate_type(&self.type_)?;
let bytes = canonical_signing_bytes(&self.type_, &self.payload, &self.cert_hash)?;
let vk = verifying_key_from_did_key(&self.signer)?;
let sig_bytes: [u8; 64] = B64U
.decode(&self.sig)
.map_err(|e| Error::Signature(format!("base64url: {e}")))?
.try_into()
.map_err(|_| Error::Signature("signature must be 64 bytes".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
vk.verify(&bytes, &sig)
.map_err(|e| Error::Signature(format!("ed25519: {e}")))?;
Ok(vk)
}
/// Reparse `payload` as `P`. Errors if the type discriminator does not
/// match `P::payload_type()`.
pub fn payload_as<P: AttestationPayload>(&self) -> Result<P> {
if self.type_ != P::payload_type() {
return Err(Error::Type(format!(
"expected '{}', got '{}'",
P::payload_type(),
self.type_
)));
}
Ok(serde_json::from_value(self.payload.clone())?)
}
}
/// Discriminator grammar: nonempty, ASCII-graphic only, slash-separated
/// segments of `[a-zA-Z0-9._+-]`, no empty segment, capped at 128 bytes.
/// Matches `covenant/exec/v1`, `slsa/v1.0`, `sigstore/dsse/v1`.
fn validate_type(t: &str) -> Result<()> {
if t.is_empty() {
return Err(Error::Type("empty discriminator".to_string()));
}
if t.len() > 128 {
return Err(Error::Type(format!(
"discriminator longer than 128 bytes ({})",
t.len()
)));
}
if !t.is_ascii() {
return Err(Error::Type(format!("discriminator must be ASCII: '{t}'")));
}
for segment in t.split('/') {
if segment.is_empty() {
return Err(Error::Type(format!(
"discriminator has an empty segment: '{t}'"
)));
}
for c in segment.chars() {
if !(c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '+' || c == '-') {
return Err(Error::Type(format!(
"discriminator segment '{segment}' has disallowed character '{c}'"
)));
}
}
}
Ok(())
}
fn canonical_signing_bytes(
type_: &str,
payload: &serde_json::Value,
cert_hash_hex: &str,
) -> Result<Vec<u8>> {
let input = SigningInput {
type_,
payload,
cert_hash: cert_hash_hex,
};
let jcs = serde_jcs::to_vec(&input).map_err(|e| Error::Jcs(e.to_string()))?;
let mut out = Vec::with_capacity(SIGNING_DOMAIN.len() + jcs.len());
out.extend_from_slice(SIGNING_DOMAIN);
out.extend_from_slice(&jcs);
Ok(out)
}
fn did_key_from_verifying_key(key: &VerifyingKey) -> String {
let mut buf = Vec::with_capacity(ED25519_MULTICODEC.len() + 32);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&key.to_bytes());
let encoded = multibase::encode(multibase::Base::Base58Btc, &buf);
format!("did:key:{encoded}")
}
/// The DID:key spec mandates base58btc (multibase `z`-prefix). Other multibase
/// encodings of the same key bytes are rejected so allowlists, dedup, and
/// signer-pinning policies that compare `signer` strings remain consistent
/// across peers.
fn verifying_key_from_did_key(did: &str) -> Result<VerifyingKey> {
let method_id = did
.strip_prefix("did:key:")
.ok_or_else(|| Error::Did(format!("not a did:key: {did}")))?;
if !method_id.starts_with('z') {
return Err(Error::Did(format!(
"did:key must use base58btc (z-prefix): {did}"
)));
}
let (base, bytes) =
multibase::decode(method_id).map_err(|e| Error::Did(format!("multibase: {e}")))?;
if base != multibase::Base::Base58Btc {
return Err(Error::Did(format!(
"did:key must use base58btc (z-prefix): {did}"
)));
}
if bytes.len() != ED25519_MULTICODEC.len() + 32
|| bytes[..ED25519_MULTICODEC.len()] != ED25519_MULTICODEC
{
return Err(Error::Did("not an ed25519 did:key".to_string()));
}
let key_bytes: [u8; 32] = bytes[ED25519_MULTICODEC.len()..]
.try_into()
.expect("length checked above");
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Did(format!("invalid ed25519 key: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn fresh() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct DummyPayload {
agent: String,
commit: String,
}
impl AttestationPayload for DummyPayload {
fn payload_type() -> &'static str {
"test/dummy/v1"
}
}
fn sample_cert_hash() -> [u8; 32] {
let mut h = [0u8; 32];
for (i, b) in h.iter_mut().enumerate() {
*b = i as u8;
}
h
}
fn dummy_attestation(sk: &SigningKey, cert_hash: [u8; 32]) -> Attestation {
Attestation::sign(
sk,
DummyPayload {
agent: "a".to_string(),
commit: "b".to_string(),
},
cert_hash,
)
.unwrap()
}
#[test]
fn sign_then_verify_roundtrip() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let payload = DummyPayload {
agent: "did:key:z6MkTest".to_string(),
commit: "deadbeef".to_string(),
};
let att = Attestation::sign(&sk, payload.clone(), cert_hash).unwrap();
let vk = att.verify_signature(cert_hash).unwrap();
assert_eq!(vk.to_bytes(), sk.verifying_key().to_bytes());
assert_eq!(att.payload_as::<DummyPayload>().unwrap(), payload);
}
#[test]
fn cross_cert_replay_fails() {
let sk = fresh();
let cert_a = sample_cert_hash();
let mut cert_b = sample_cert_hash();
cert_b[0] ^= 0xff;
let att = dummy_attestation(&sk, cert_a);
let err = att.verify_signature(cert_b).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
#[test]
fn tampered_payload_fails_verify() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&sk, cert_hash);
att.payload = serde_json::json!({ "agent": "evil", "commit": "b" });
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
#[test]
fn payload_as_wrong_type_errors() {
let sk = fresh();
let att = dummy_attestation(&sk, sample_cert_hash());
#[derive(Debug, Serialize, Deserialize)]
struct Wrong {
#[allow(dead_code)]
x: String,
}
impl AttestationPayload for Wrong {
fn payload_type() -> &'static str {
"test/other/v1"
}
}
let err = att.payload_as::<Wrong>().unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn json_roundtrip_preserves_signature() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let att = dummy_attestation(&sk, cert_hash);
let json = serde_json::to_string(&att).unwrap();
let back: Attestation = serde_json::from_str(&json).unwrap();
back.verify_signature(cert_hash).unwrap();
}
/// Wire shape is part of the public protocol — pin field names so an
/// accidental rename surfaces as a test failure, not silent interop drift.
#[test]
fn wire_shape_pins_field_names() {
let sk = fresh();
let att = dummy_attestation(&sk, sample_cert_hash());
let v: serde_json::Value = serde_json::to_value(&att).unwrap();
let obj = v.as_object().expect("attestation must serialize as object");
let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
let expected: std::collections::BTreeSet<&str> =
["type", "payload", "cert_hash", "signer", "sig"]
.into_iter()
.collect();
assert_eq!(keys, expected);
assert!(obj["type"].is_string());
assert!(obj["cert_hash"].is_string());
assert!(obj["signer"].is_string());
assert!(obj["sig"].is_string());
}
/// The spec mandates lowercase hex on the wire. An uppercase cert_hash
/// is rejected as `CertHashMismatch` so a non-canonical signer can't
/// produce attestations that verify against some peers but not others.
#[test]
fn uppercase_cert_hash_is_rejected_as_non_canonical() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let mut att = dummy_attestation(&sk, cert_hash);
att.cert_hash = att.cert_hash.to_uppercase();
let err = att.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
#[test]
fn empty_type_rejected_at_signing() {
#[derive(Serialize, Deserialize)]
struct Empty {}
impl AttestationPayload for Empty {
fn payload_type() -> &'static str {
""
}
}
let sk = fresh();
let err = Attestation::sign(&sk, Empty {}, sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn validate_type_grammar() {
validate_type("covenant/exec/v1").unwrap();
validate_type("slsa/v1.0").unwrap();
validate_type("sigstore/dsse/v1").unwrap();
validate_type("a").unwrap();
validate_type("a.b-c_d+e/x").unwrap();
for bad in [
"",
" ",
"covenant/exec/v1 ",
"covenant//v1",
"/covenant/v1",
"covenant/v1/",
"covenant\x00exec/v1",
"covenant/exec\nv1",
"сovenant/exec/v1",
"covenant/ex ec/v1",
"covenant/exec/v1#frag",
] {
let err = validate_type(bad).unwrap_err();
assert!(matches!(err, Error::Type(_)), "must reject '{bad}'");
}
let too_long = "a/".repeat(70);
let err = validate_type(&too_long).unwrap_err();
assert!(matches!(err, Error::Type(_)));
}
#[test]
fn did_key_must_be_base58btc() {
// Sign with a real key, then re-encode the public-key bytes in a
// different multibase. Verification must reject.
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&ED25519_MULTICODEC);
buf.extend_from_slice(&sk.verifying_key().to_bytes());
for base in [
multibase::Base::Base64Url,
multibase::Base::Base32Lower,
multibase::Base::Base16Lower,
] {
att.signer = format!("did:key:{}", multibase::encode(base, &buf));
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)), "must reject base {base:?}");
}
}
#[test]
fn verify_rejects_did_without_did_key_prefix() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.signer = "ed25519:abc".to_string();
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_did_with_wrong_multicodec() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
// 0x12 0x00 = sha2-256 multicodec — wrong for an ed25519 public key.
let mut buf = vec![0x12, 0x00];
buf.extend_from_slice(&sk.verifying_key().to_bytes());
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_did_with_wrong_key_length() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
let mut buf = Vec::with_capacity(33);
buf.extend_from_slice(&ED25519_MULTICODEC);
// 31 bytes instead of 32.
buf.extend_from_slice(&sk.verifying_key().to_bytes()[..31]);
att.signer = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &buf)
);
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Did(_)));
}
#[test]
fn verify_rejects_bad_signature_base64() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.sig = "!!not-base64!!".to_string();
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
#[test]
fn verify_rejects_signature_wrong_length() {
let sk = fresh();
let mut att = dummy_attestation(&sk, sample_cert_hash());
att.sig = B64U.encode([0u8; 32]); // valid base64, wrong size.
let err = att.verify_signature(sample_cert_hash()).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
/// A payload that happens to contain a `cert_hash` field of its own does
/// not interfere with the outer binding: the attestation envelope's
/// `cert_hash` is the only field consulted by `verify_signature`, and the
/// payload is treated as opaque JSON throughout. A reviewer worried about
/// shadowing or smuggling can verify this directly.
#[test]
fn payload_cert_hash_field_does_not_shadow_outer_binding() {
#[derive(Serialize, Deserialize)]
struct Sneaky {
cert_hash: String,
x: String,
}
impl AttestationPayload for Sneaky {
fn payload_type() -> &'static str {
"test/sneaky/v1"
}
}
let sk = fresh();
let outer_hash = sample_cert_hash();
let att = Attestation::sign(
&sk,
Sneaky {
cert_hash: "forged-by-the-attacker".to_string(),
x: "y".to_string(),
},
outer_hash,
)
.unwrap();
// Envelope's cert_hash is the outer (real) hash, lowercase hex.
assert_eq!(att.cert_hash, hex::encode(outer_hash));
// Payload retains its own field — opaque to the protocol.
assert_eq!(
att.payload.get("cert_hash").and_then(|v| v.as_str()),
Some("forged-by-the-attacker"),
);
// Verification binds to the outer envelope's cert_hash.
att.verify_signature(outer_hash).unwrap();
// And rejects when the outer cert hash differs.
let mut wrong = outer_hash;
wrong[0] ^= 0xff;
let err = att.verify_signature(wrong).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
/// A different protocol that also signs `{type, payload, cert_hash}` with
/// the same key would still fail attestation verification because the
/// signing input carries a domain tag the other protocol does not include.
#[test]
fn cross_protocol_replay_is_rejected_by_domain_separation() {
let sk = fresh();
let cert_hash = sample_cert_hash();
let att = dummy_attestation(&sk, cert_hash);
// Sign the same JCS bytes WITHOUT the domain tag and graft the
// signature back onto the attestation.
let cert_hash_hex = hex::encode(cert_hash);
let bare = serde_jcs::to_vec(&SigningInput {
type_: &att.type_,
payload: &att.payload,
cert_hash: &cert_hash_hex,
})
.unwrap();
let other_sig: Signature = sk.sign(&bare);
let mut tampered = att.clone();
tampered.sig = B64U.encode(other_sig.to_bytes());
let err = tampered.verify_signature(cert_hash).unwrap_err();
assert!(matches!(err, Error::Signature(_)));
}
}
-327
View File
@@ -1,327 +0,0 @@
//! Envelope around a `gitlawb/ref-update/v1` cert plus optional attestations,
//! and the cert-hash helper attestations bind to.
//!
//! An empty envelope serializes to the same bytes as the underlying bare cert.
//! `cert_hash` strips `signatures` and `attestations`, JCS-encodes the rest,
//! and SHA-256s; the result is what every attestation binds to.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::attestation::Attestation;
use crate::error::{Error, Result};
use crate::verifier::{Registry, VerifiedAttestation};
/// The discriminator the wrapped cert must carry. Matches the constant of the
/// same name in `gitlawb-core::cert`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
/// A ref-update cert with optional attestations.
///
/// The cert is flattened into the top-level JSON, so an empty envelope
/// produces the same bytes as a bare cert and existing decoders ignore the
/// extra `attestations` field. The cert is held as `serde_json::Value` so
/// this crate has no runtime dependency on `gitlawb-core::cert::RefUpdateCert`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttestedRefUpdateCert {
/// The cert body and signatures, flattened to the top level.
#[serde(flatten)]
pub cert: serde_json::Value,
/// Attached attestations. Empty when absent on the wire.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attestations: Vec<Attestation>,
}
impl AttestedRefUpdateCert {
/// Wrap a cert. No attestations are attached yet.
///
/// The serialized cert must be a JSON object whose `type` field equals
/// [`CERT_TYPE`] and which does **not** already carry an `attestations`
/// field. The second rule catches two cases that would otherwise leave
/// the envelope in an inconsistent in-memory state:
///
/// - Double-wrap: `from_cert(&previously_wrapped_envelope)` would put the
/// inner attestations on `self.cert` and leave `self.attestations` empty.
/// - Pre-attested cert from a peer: a malformed or malicious cert with an
/// attached `attestations` field would survive `cert_hash` (which strips
/// the field) but bypass the wrapper's append-and-verify path entirely.
///
/// Both are rejected with [`Error::Payload`]. To re-wrap, deserialize the
/// envelope as `AttestedRefUpdateCert` directly.
pub fn from_cert<T: Serialize>(cert: &T) -> Result<Self> {
let value = serde_json::to_value(cert)?;
let obj = value
.as_object()
.ok_or_else(|| Error::Payload("cert must serialize as a JSON object".to_string()))?;
match obj.get("type").and_then(|v| v.as_str()) {
Some(t) if t == CERT_TYPE => {}
Some(other) => {
return Err(Error::Payload(format!(
"cert type must be '{CERT_TYPE}', got '{other}'"
)));
}
None => {
return Err(Error::Payload(format!(
"cert missing 'type' field (expected '{CERT_TYPE}')"
)));
}
}
if obj.contains_key("attestations") {
return Err(Error::Payload(
"cert already carries an 'attestations' field; deserialize as \
AttestedRefUpdateCert directly instead of re-wrapping"
.to_string(),
));
}
Ok(Self {
cert: value,
attestations: Vec::new(),
})
}
/// The 32-byte cert hash every attached attestation must bind to.
pub fn cert_hash(&self) -> Result<[u8; 32]> {
cert_hash(&self.cert)
}
/// Append an already-signed attestation.
pub fn attach(&mut self, attestation: Attestation) {
self.attestations.push(attestation);
}
/// Verify every attached attestation against this envelope's cert hash,
/// applying the registry's policy. Equivalent to
/// `registry.verify_all(&self.attestations, self.cert_hash()?)` but
/// reads as one operation at call sites.
pub fn verify_attestations(&self, registry: &Registry) -> Result<Vec<VerifiedAttestation>> {
registry.verify_all(&self.attestations, self.cert_hash()?)
}
}
/// SHA-256 of the cert body. The body must be a JSON object; `signatures` and
/// `attestations` are stripped before JCS encoding (RFC 8785), so the hash is
/// reproducible across languages and JSON libraries and is stable across
/// countersignatures.
pub fn cert_hash(cert: &serde_json::Value) -> Result<[u8; 32]> {
let mut body = cert.clone();
let obj = body
.as_object_mut()
.ok_or_else(|| Error::Payload("cert body must be a JSON object".to_string()))?;
obj.remove("signatures");
obj.remove("attestations");
let bytes = serde_jcs::to_vec(&body).map_err(|e| Error::Jcs(e.to_string()))?;
let mut h = Sha256::new();
h.update(&bytes);
let out = h.finalize();
let mut arr = [0u8; 32];
arr.copy_from_slice(&out);
Ok(arr)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn bare_cert() -> serde_json::Value {
json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1",
"signatures": [{"signer": "did:key:z6MkRepo", "sig": "abc"}]
})
}
#[test]
fn empty_envelope_serializes_as_bare_cert() {
let bare = bare_cert();
let env = AttestedRefUpdateCert::from_cert(&bare).unwrap();
let env_json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap();
let bare_keys: std::collections::BTreeSet<&String> =
bare.as_object().unwrap().keys().collect();
let env_keys: std::collections::BTreeSet<&String> =
env_json.as_object().unwrap().keys().collect();
assert_eq!(bare_keys, env_keys);
}
#[test]
fn bare_cert_parses_as_envelope_with_no_attestations() {
let bare = bare_cert();
let env: AttestedRefUpdateCert = serde_json::from_value(bare.clone()).unwrap();
assert!(env.attestations.is_empty());
assert_eq!(env.cert, bare);
}
#[test]
fn cert_hash_is_stable_across_countersignatures() {
let base_body = json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1"
});
let mut one_sig = base_body.clone();
one_sig["signatures"] = json!([{"signer": "x", "sig": "y"}]);
let mut two_sigs = base_body.clone();
two_sigs["signatures"] = json!([
{"signer": "x", "sig": "y"},
{"signer": "a", "sig": "b"}
]);
assert_eq!(cert_hash(&one_sig).unwrap(), cert_hash(&two_sigs).unwrap());
}
#[test]
fn cert_hash_changes_when_body_changes() {
let body_a = json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkRepo",
"ref_name": "refs/heads/main",
"from": "0".repeat(64),
"to": "a".repeat(64),
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "n1",
"signatures": []
});
let mut body_b = body_a.clone();
body_b["to"] = json!("b".repeat(64));
assert_ne!(cert_hash(&body_a).unwrap(), cert_hash(&body_b).unwrap());
}
#[test]
fn cert_hash_rejects_non_object_root() {
let err = cert_hash(&json!(null)).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
let err = cert_hash(&json!([1, 2, 3])).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
let err = cert_hash(&json!("scalar")).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_wrong_type() {
let wrong = json!({"type": "gitlawb/ref-update/v0", "repo": "did:key:z"});
let err = AttestedRefUpdateCert::from_cert(&wrong).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_missing_type() {
let no_type = json!({"repo": "did:key:z"});
let err = AttestedRefUpdateCert::from_cert(&no_type).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn from_cert_rejects_non_object() {
let arr = json!([1, 2, 3]);
let err = AttestedRefUpdateCert::from_cert(&arr).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
#[test]
fn mutating_env_cert_changes_the_hash() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
let hash_before = env.cert_hash().unwrap();
env.cert.as_object_mut().unwrap().insert(
"nonce".to_string(),
serde_json::Value::String("mutated".to_string()),
);
let hash_after = env.cert_hash().unwrap();
assert_ne!(hash_before, hash_after);
}
/// A cert that already carries an `attestations` field is rejected
/// regardless of the field's contents. Catches double-wrap (where the
/// inner attestations would otherwise be smuggled into `self.cert`) and
/// peer-supplied "pre-attested" certs that would bypass the wrapper.
#[test]
fn from_cert_rejects_preexisting_attestations_field() {
let mut sneaky = bare_cert();
sneaky["attestations"] = json!([{"smuggled": true}]);
let err = AttestedRefUpdateCert::from_cert(&sneaky).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
// Empty array still rejected — the field's mere presence is the signal.
let mut empty = bare_cert();
empty["attestations"] = json!([]);
let err = AttestedRefUpdateCert::from_cert(&empty).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `from_cert` on an already-wrapped envelope is the same case as
/// `from_cert` on a cert with a pre-existing `attestations` field: serde
/// flattens the envelope back into a JSON object that carries the field.
#[test]
fn from_cert_rejects_a_previously_wrapped_envelope() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
// Inject an attestation-shaped value so the wire round-trip would
// actually re-emit the field.
env.cert
.as_object_mut()
.unwrap()
.insert("attestations".to_string(), json!([]));
let err = AttestedRefUpdateCert::from_cert(&env).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `attestations: null` is explicitly not the same as missing. The
/// `#[serde(default)]` attribute only fills in when the key is absent —
/// a present-but-null wire field is rejected, which surfaces malformed
/// payloads early instead of silently treating them as empty.
#[test]
fn attestations_null_on_wire_is_rejected() {
let mut with_null = bare_cert();
with_null["attestations"] = serde_json::Value::Null;
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_null).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("expected a sequence") || msg.contains("invalid type"),
"unexpected error: {msg}"
);
}
#[test]
fn attestations_string_on_wire_is_rejected() {
let mut with_str = bare_cert();
with_str["attestations"] = serde_json::Value::String("oops".to_string());
let err = serde_json::from_value::<AttestedRefUpdateCert>(with_str).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected a sequence") || msg.contains("invalid type"));
}
/// `attach` does not deduplicate. Duplicate attestations are a verifier-
/// policy concern (e.g., "at most one SLSA provenance"), not a wrapper
/// concern. Pinning the behavior so a future deduplication change is an
/// intentional break.
#[test]
fn attach_does_not_deduplicate() {
let mut env = AttestedRefUpdateCert::from_cert(&bare_cert()).unwrap();
let att = Attestation {
type_: "demo/v1".to_string(),
payload: serde_json::json!({}),
cert_hash: "deadbeef".to_string(),
signer: "did:key:z6MkDup".to_string(),
sig: "AAAA".to_string(),
};
env.attach(att.clone());
env.attach(att);
assert_eq!(env.attestations.len(), 2);
}
}
-46
View File
@@ -1,46 +0,0 @@
use thiserror::Error;
/// Errors from building or verifying an attestation.
#[derive(Debug, Error)]
pub enum Error {
/// Discriminator failed validation (empty, whitespace, control char,
/// disallowed grammar).
#[error("attestation type: {0}")]
Type(String),
/// Attestation was signed against a different cert.
#[error("cert hash mismatch")]
CertHashMismatch,
/// Ed25519 signature failed verification.
#[error("signature: {0}")]
Signature(String),
/// DID could not be parsed.
#[error("did: {0}")]
Did(String),
/// Payload structure check failed, or the cert body was not a JSON object.
#[error("payload: {0}")]
Payload(String),
/// No verifier registered for the type and policy required one.
#[error("no verifier registered for type '{0}'")]
UnknownType(String),
/// `RequireAll` policy did not find an entry for a required type, or the
/// entry was not fully verified.
#[error("required attestation type missing or unverified: '{0}'")]
RequiredMissing(String),
/// JSON failure.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// JCS encoding failure.
#[error("jcs: {0}")]
Jcs(String),
}
/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
-132
View File
@@ -1,132 +0,0 @@
//! Pluggable provenance attachments for gitlawb ref-update certs.
//!
//! A `gitlawb/ref-update/v1` cert proves who pushed and where a ref moved to.
//! It does not say how the commit was produced. This crate adds an optional
//! `attestations` field that lets any provenance system (SLSA, Sigstore,
//! in-toto, agent runtimes) attach a typed, signed blob bound to the cert by
//! its hash.
//!
//! The cert format stays additive. A cert with no attestations serializes the
//! same bytes it does today, and existing decoders (which do not set
//! `deny_unknown_fields`) silently drop the new `attestations` field rather
//! than reject the cert.
//!
//! ## Wire shape
//!
//! ```json
//! {
//! "type": "gitlawb/ref-update/v1",
//! // ...standard cert fields...,
//! "signatures": [...],
//! "attestations": [
//! {
//! "type": "covenant/exec/v1",
//! "payload": { /* opaque, type-specific */ },
//! "cert_hash": "<sha256 hex of JCS-encoded cert body>",
//! "signer": "did:key:z6Mk...",
//! "sig": "<base64url ed25519 over the signing input>"
//! }
//! ]
//! }
//! ```
//!
//! ## Canonical bytes
//!
//! Hashing and signing inputs use JCS (RFC 8785) so they reproduce across
//! implementations regardless of struct field order or JSON library.
//!
//! The cert hash is `SHA-256(JCS(cert_body_without_signatures_or_attestations))`,
//! expressed as lowercase hex on the wire. Uppercase is non-canonical and
//! rejected on verify so the JCS signing input is uniquely determined by the
//! cert bytes alone — there is no implicit normalization rule that
//! cross-language signers might miss.
//!
//! The attestation signing input is the byte string
//! `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. The
//! domain tag is in the byte stream, not the canonical JSON, so an attestation
//! cannot be confused with a same-shape JCS document signed for a different
//! protocol that uses the same Ed25519 key.
//!
//! ## DID:key
//!
//! Signers are `did:key` DIDs over Ed25519, encoded with the multibase
//! base58btc (`z`) prefix per the W3C DID:key specification. Non-base58btc
//! encodings of the same public key are rejected on verify so allowlists,
//! deduplication, and signer-pinning policies that compare `signer` strings
//! stay consistent across peers.
//!
//! ## Verification
//!
//! [`Registry`] looks up a verifier by type discriminator. [`Policy`] decides
//! what to do when no verifier matches: `AcceptKnown` (default) lets unknown
//! types pass without trust; `RequireAll` enforces a per-repo allowlist while
//! still letting unrelated attached attestations through unverified (so a
//! third-party attachment cannot DoS the cert); `RejectUnknown` rejects
//! anything unregistered.
//!
//! ## Quick start
//!
//! ```
//! use ed25519_dalek::SigningKey;
//! use gitlawb_attest::{
//! Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert,
//! Error, Policy, Registry, Result, CERT_TYPE,
//! };
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize)]
//! struct ExecPayload { agent: String }
//! impl AttestationPayload for ExecPayload {
//! fn payload_type() -> &'static str { "covenant/exec/v1" }
//! }
//!
//! struct ExecVerifier;
//! impl AttestationVerifier for ExecVerifier {
//! fn type_(&self) -> &'static str { "covenant/exec/v1" }
//! fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
//! payload.get("agent").and_then(|v| v.as_str())
//! .ok_or_else(|| Error::Payload("missing agent".into()))?;
//! Ok(())
//! }
//! }
//!
//! # fn run() -> Result<()> {
//! let cert = serde_json::json!({
//! "type": CERT_TYPE, "repo": "did:key:z6MkRepo",
//! "ref_name": "refs/heads/main",
//! "from": "0".repeat(64), "to": "a".repeat(64),
//! "seq": 1, "timestamp": "2026-01-01T00:00:00Z", "nonce": "n1",
//! "signatures": []
//! });
//! let mut env = AttestedRefUpdateCert::from_cert(&cert)?;
//!
//! let sk = SigningKey::from_bytes(&[7u8; 32]);
//! let payload = ExecPayload { agent: "did:key:z6MkAgent".into() };
//! let att = Attestation::sign(&sk, payload, env.cert_hash()?)?;
//! env.attach(att);
//!
//! let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
//! registry.register(ExecVerifier);
//! let verified = env.verify_attestations(&registry)?;
//! assert_eq!(verified[0].type_, "covenant/exec/v1");
//! assert!(verified[0].fully_verified);
//! # Ok(()) }
//! # run().unwrap();
//! ```
pub mod attestation;
pub mod cert;
pub mod error;
pub mod verifier;
pub use attestation::{Attestation, AttestationPayload};
pub use cert::{cert_hash, AttestedRefUpdateCert, CERT_TYPE};
pub use error::{Error, Result};
pub use verifier::{AttestationVerifier, Policy, Registry, VerifiedAttestation};
/// Version string for the attestation envelope wire shape.
///
/// Bumped only when the binding rules — JCS input shape, domain-separation
/// tag, hash algorithm — change. Independent of [`CERT_TYPE`], which versions
/// the underlying ref-update cert.
pub const ATTEST_ENVELOPE_VERSION: &str = "v1";
-377
View File
@@ -1,377 +0,0 @@
//! Verifier trait, registry, and policy.
//!
//! A [`Registry`] maps a type discriminator to a verifier. [`Policy`] decides
//! what happens for types nothing is registered for: accept without trust,
//! reject, or require an allowlist.
//!
//! Under [`Policy::RequireAll`] the registry never short-circuits on unknown
//! types — any attestation can be attached by any third party, so blocking
//! the cert because an attacker added an unrelated `attacker/spam/v1` would
//! be a denial-of-service vector. Unknown types are silently treated as
//! `fully_verified = false`; the allowlist is enforced only by the
//! presence-and-verified check on `required_types` after the batch runs.
use std::collections::HashMap;
use crate::attestation::Attestation;
use crate::error::{Error, Result};
/// Verifier for one attestation type. Implementors declare the discriminator
/// they handle and validate the payload's shape. The signature and cert-hash
/// binding are checked by [`Registry::verify`] before `verify_payload` runs.
pub trait AttestationVerifier: Send + Sync {
/// The discriminator this verifier handles. Must be a `'static` literal
/// so the registry can key on it without copying.
fn type_(&self) -> &'static str;
/// Validate payload structure. Signature is already verified.
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()>;
}
/// What to do for attestation types with no registered verifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Policy {
/// Unknown types pass but are flagged `fully_verified = false`. Default.
#[default]
AcceptKnown,
/// Every entry in `required_types` must be present and fully verified.
/// Unknown types beyond the required set are still accepted unverified,
/// so a malicious extra attestation cannot DoS the cert.
RequireAll,
/// Reject every attestation whose type is not registered.
RejectUnknown,
}
/// Result of verifying one attestation.
#[derive(Debug, Clone)]
pub struct VerifiedAttestation {
/// Type discriminator.
pub type_: String,
/// Signer's `did:key`.
pub signer: String,
/// Lowercase SHA-256 hex of the cert body the attestation bound to.
pub cert_hash: String,
/// `true` when a verifier ran a payload check and accepted it. `false`
/// when the policy let an unknown type through without one.
pub fully_verified: bool,
}
/// Verifiers keyed by type discriminator.
#[derive(Default)]
pub struct Registry {
by_type: HashMap<&'static str, Box<dyn AttestationVerifier>>,
policy: Policy,
required_types: Vec<String>,
}
impl Registry {
/// Empty registry with the default policy.
pub fn new() -> Self {
Self::default()
}
/// Register a verifier by value. Returns the prior verifier for the same
/// type, if any, so callers can detect double-registration.
pub fn register<V: AttestationVerifier + 'static>(
&mut self,
verifier: V,
) -> Option<Box<dyn AttestationVerifier>> {
self.register_boxed(Box::new(verifier))
}
/// Like [`Self::register`] but takes an already-boxed trait object — useful
/// when verifiers are constructed dynamically.
pub fn register_boxed(
&mut self,
verifier: Box<dyn AttestationVerifier>,
) -> Option<Box<dyn AttestationVerifier>> {
let key = verifier.type_();
self.by_type.insert(key, verifier)
}
/// Switch policy.
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
/// Type discriminators that must be present and verified under
/// `Policy::RequireAll`. Ignored under other policies.
pub fn require_types<I, S>(mut self, types: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.required_types = types.into_iter().map(Into::into).collect();
self
}
/// Active policy.
pub fn policy(&self) -> Policy {
self.policy
}
/// Verify signature, cert-hash binding, and (if a verifier is registered)
/// payload structure. Under `RequireAll` this is lenient on unknown types
/// — see the module docstring.
pub fn verify(
&self,
attestation: &Attestation,
expected_cert_hash: [u8; 32],
) -> Result<VerifiedAttestation> {
attestation.verify_signature(expected_cert_hash)?;
let fully = match self.by_type.get(attestation.type_.as_str()) {
Some(v) => {
v.verify_payload(&attestation.payload)?;
true
}
None => match self.policy {
Policy::AcceptKnown | Policy::RequireAll => false,
Policy::RejectUnknown => {
return Err(Error::UnknownType(attestation.type_.clone()));
}
},
};
Ok(VerifiedAttestation {
type_: attestation.type_.clone(),
signer: attestation.signer.clone(),
cert_hash: attestation.cert_hash.clone(),
fully_verified: fully,
})
}
/// Verify a batch, then enforce `RequireAll`.
pub fn verify_all(
&self,
attestations: &[Attestation],
expected_cert_hash: [u8; 32],
) -> Result<Vec<VerifiedAttestation>> {
let mut verified = Vec::with_capacity(attestations.len());
for a in attestations {
verified.push(self.verify(a, expected_cert_hash)?);
}
if self.policy == Policy::RequireAll {
for required in &self.required_types {
let present = verified
.iter()
.any(|v| &v.type_ == required && v.fully_verified);
if !present {
return Err(Error::RequiredMissing(required.clone()));
}
}
}
Ok(verified)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{Attestation, AttestationPayload};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn fresh() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Serialize, Deserialize)]
struct Demo {
a: String,
}
impl AttestationPayload for Demo {
fn payload_type() -> &'static str {
"demo/v1"
}
}
struct DemoVerifier;
impl AttestationVerifier for DemoVerifier {
fn type_(&self) -> &'static str {
"demo/v1"
}
fn verify_payload(&self, payload: &serde_json::Value) -> Result<()> {
payload
.get("a")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|_| ())
.ok_or_else(|| Error::Payload("missing or empty 'a'".to_string()))
}
}
/// A second verifier that has the same discriminator as `DemoVerifier` —
/// used to test last-wins on `register`.
struct DemoVerifierAlt;
impl AttestationVerifier for DemoVerifierAlt {
fn type_(&self) -> &'static str {
"demo/v1"
}
fn verify_payload(&self, _payload: &serde_json::Value) -> Result<()> {
Err(Error::Payload("alt always rejects".to_string()))
}
}
#[derive(Serialize, Deserialize)]
struct Other {
x: u32,
}
impl AttestationPayload for Other {
fn payload_type() -> &'static str {
"other/v1"
}
}
fn sample_hash() -> [u8; 32] {
let mut h = [0u8; 32];
for (i, b) in h.iter_mut().enumerate() {
*b = (i + 1) as u8;
}
h
}
fn signed_demo(sk: &SigningKey, cert_hash: [u8; 32], a: &str) -> Attestation {
Attestation::sign(sk, Demo { a: a.to_string() }, cert_hash).unwrap()
}
fn signed_other(sk: &SigningKey, cert_hash: [u8; 32], x: u32) -> Attestation {
Attestation::sign(sk, Other { x }, cert_hash).unwrap()
}
#[test]
fn registry_accepts_registered_type() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new();
reg.register(DemoVerifier);
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
assert!(v.fully_verified);
assert_eq!(v.type_, "demo/v1");
assert_eq!(v.cert_hash, hex::encode(cert_hash));
}
#[test]
fn accept_known_lets_unknown_pass_unverified() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new().with_policy(Policy::AcceptKnown);
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify(&att, cert_hash).unwrap();
assert!(!v.fully_verified);
}
#[test]
fn reject_unknown_blocks_unregistered_types() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new().with_policy(Policy::RejectUnknown);
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::UnknownType(_)));
}
#[test]
fn require_all_enforces_presence() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
let err = reg.verify_all(&[], cert_hash).unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
let att = signed_demo(&sk, cert_hash, "ok");
let v = reg.verify_all(&[att], cert_hash).unwrap();
assert_eq!(v.len(), 1);
}
/// Even when a junk attestation of an unrelated type is attached first, a
/// `RequireAll` registry must still accept the cert as long as the
/// required type is present and fully verified — the attacker shouldn't
/// be able to DoS the cert by attaching `attacker/spam/v1` before
/// `covenant/exec/v1`.
#[test]
fn require_all_is_lenient_on_unknown_types_in_the_batch() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]);
reg.register(DemoVerifier);
let spam = signed_other(&sk, cert_hash, 7);
let real = signed_demo(&sk, cert_hash, "ok");
let verified = reg.verify_all(&[spam, real], cert_hash).unwrap();
assert_eq!(verified.len(), 2);
assert!(verified
.iter()
.any(|v| v.type_ == "demo/v1" && v.fully_verified));
assert!(verified
.iter()
.any(|v| v.type_ == "other/v1" && !v.fully_verified));
}
/// A required type that is present but unverified (no verifier registered
/// even though the type appears in `required_types`) must fail
/// `verify_all` with `RequiredMissing`. Catches a misconfiguration where
/// the operator listed a type but forgot to register a verifier.
#[test]
fn require_all_rejects_present_but_unverified_required_type() {
let sk = fresh();
let cert_hash = sample_hash();
let reg = Registry::new()
.with_policy(Policy::RequireAll)
.require_types(["demo/v1"]); // type listed, but no verifier registered.
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify_all(&[att], cert_hash).unwrap_err();
assert!(matches!(err, Error::RequiredMissing(t) if t == "demo/v1"));
}
#[test]
fn payload_check_failure_rejects() {
let sk = fresh();
let cert_hash = sample_hash();
let mut reg = Registry::new();
reg.register(DemoVerifier);
// Sign an empty `a` so the signature verifies but the payload check fails.
let att = Attestation::sign(&sk, Demo { a: String::new() }, cert_hash).unwrap();
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
/// `RequireAll` with an empty allowlist degenerates to "no required types
/// to check" — the loop over `required_types` is a no-op, so an empty
/// batch verifies successfully. Pinned so operators relying on the empty
/// allowlist as a default-permissive RequireAll see consistent behavior.
#[test]
fn require_all_with_empty_allowlist_and_empty_batch_succeeds() {
let reg = Registry::new().with_policy(Policy::RequireAll);
let verified = reg.verify_all(&[], sample_hash()).unwrap();
assert!(verified.is_empty());
}
#[test]
fn register_returns_the_prior_verifier_on_double_register() {
let mut reg = Registry::new();
assert!(reg.register(DemoVerifier).is_none());
let prior = reg.register(DemoVerifierAlt);
let prior = prior.expect("second register must return the previous verifier");
assert_eq!(prior.type_(), "demo/v1");
// Last-wins semantics confirmed by behavior.
let sk = fresh();
let cert_hash = sample_hash();
let att = signed_demo(&sk, cert_hash, "ok");
let err = reg.verify(&att, cert_hash).unwrap_err();
assert!(matches!(err, Error::Payload(_)));
}
}
@@ -1,150 +0,0 @@
//! Canonical test vectors.
//!
//! Ed25519 signatures are deterministic (RFC 8032). JCS canonicalization is
//! deterministic (RFC 8785). SHA-256 is deterministic. Given fixed inputs the
//! cert hash, signature, signer DID, and full wire JSON are therefore exactly
//! reproducible across implementations. This file pins those values so a
//! port to Go, JS, Python, or any other language has a concrete oracle to
//! check against without reading Rust.
//!
//! If a vector changes, that is a wire-protocol break. The change is either
//! intentional (bump `ATTEST_ENVELOPE_VERSION` and document the migration) or
//! a regression that broke interoperability with every other implementation.
use ed25519_dalek::SigningKey;
use gitlawb_attest::{Attestation, AttestationPayload, AttestedRefUpdateCert, Registry, CERT_TYPE};
use serde::{Deserialize, Serialize};
/// Fixed seed for the attestation signer. Real signers use a CSPRNG; this is a
/// test-vector convention only.
const SIGNER_SEED: [u8; 32] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
];
/// Fixed cert body for the vectors. Mirrors the field layout of a
/// `gitlawb-core::cert::RefUpdateBody` plus a `signatures` array. The
/// signatures array is deliberately non-empty to test that it is stripped
/// before hashing.
fn fixture_cert_body() -> serde_json::Value {
serde_json::json!({
"type": CERT_TYPE,
"repo": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt",
"ref_name": "refs/heads/main",
"from": "0000000000000000000000000000000000000000000000000000000000000000",
"to": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"nonce": "vector-nonce-1",
"signatures": [
{"signer": "did:key:z6MkpYqTAt8jq3SExeUiVdyTrDdocy9j2NjGxFE6S6ZBh1Yt", "sig": "ignored"}
]
})
}
/// Fixed attestation payload. Shape mirrors what Covenant's runtime would
/// produce for a `covenant/exec/v1` attestation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct ExecPayload {
agent: String,
capability: String,
sandbox_digest: String,
}
impl AttestationPayload for ExecPayload {
fn payload_type() -> &'static str {
"covenant/exec/v1"
}
}
fn fixture_payload() -> ExecPayload {
ExecPayload {
agent: "did:key:z6MkAgentTestVector".to_string(),
capability: "code.write+sandbox.exec".to_string(),
sandbox_digest: "sha256:c0ffee".to_string(),
}
}
/// The expected lowercase-hex SHA-256 of `JCS(cert_body \ {signatures})`.
/// Any port that produces a different value is not JCS-canonicalizing the
/// same way (likely culprits: key ordering, integer or string escaping).
const EXPECTED_CERT_HASH_HEX: &str =
"5898d7c7a7d32a5faaaf9a52bfc92e0eff98027554adeefba0c575816f43ce56";
/// The expected `did:key` of the signer derived from `SIGNER_SEED`. Any port
/// that produces a different value has a multibase or multicodec encoding bug.
const EXPECTED_SIGNER_DID: &str = "did:key:z6MkqkKY69DYo23W4YFDoCNgjr7cMvmJqHtJVoQkEzrbaopZ";
/// The expected base64url-no-pad Ed25519 signature over
/// `b"gitlawb-attest-sig/v1\n" || JCS({type, payload, cert_hash})`. Any port
/// that produces a different value is missing the domain tag, JCS-encoding
/// the input differently, or using a different Ed25519 implementation.
const EXPECTED_SIGNATURE_B64URL: &str =
"4L3ONbhrksB9pn--u86lMeAdePSiZZplAsxuN2Cy6vgI30ndle7gDUVuoGRJr4Ylhz7ipQvUzXaDTvZGFLPXBQ";
#[test]
fn cert_hash_is_deterministic_from_jcs() {
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let actual_hex = hex::encode(env.cert_hash().unwrap());
assert_eq!(
actual_hex, EXPECTED_CERT_HASH_HEX,
"cert hash drifted — JCS encoding or hash algorithm changed",
);
}
#[test]
fn signer_did_is_deterministic_from_seed() {
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
assert_eq!(
att.signer, EXPECTED_SIGNER_DID,
"signer DID drifted — multibase or multicodec encoding changed",
);
}
#[test]
fn attestation_signature_is_deterministic() {
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
assert_eq!(
att.sig, EXPECTED_SIGNATURE_B64URL,
"signature drifted — domain tag, JCS shape, or Ed25519 implementation changed",
);
}
#[test]
fn vector_attestation_verifies_through_the_public_api() {
// Round-trip the entire pipeline using only the public API on the fixed
// vectors. A non-Rust implementor should be able to:
// 1. Compute cert_hash and check against EXPECTED_CERT_HASH_HEX.
// 2. Derive signer DID and check against EXPECTED_SIGNER_DID.
// 3. Sign a payload identical to fixture_payload() and check against
// EXPECTED_SIGNATURE_B64URL.
// 4. Reconstruct the wire envelope and verify it round-trips.
let sk = SigningKey::from_bytes(&SIGNER_SEED);
let mut env = AttestedRefUpdateCert::from_cert(&fixture_cert_body()).unwrap();
let cert_hash = env.cert_hash().unwrap();
let att = Attestation::sign(&sk, fixture_payload(), cert_hash).unwrap();
env.attach(att);
let wire = serde_json::to_string(&env).unwrap();
let back: AttestedRefUpdateCert = serde_json::from_str(&wire).unwrap();
assert_eq!(back.attestations.len(), 1);
assert_eq!(back.attestations[0].sig, EXPECTED_SIGNATURE_B64URL);
let recovered = back.attestations[0]
.verify_signature(back.cert_hash().unwrap())
.unwrap();
assert_eq!(recovered.to_bytes(), sk.verifying_key().to_bytes());
// And through a registry, with the convenience method.
let reg = Registry::new();
let verified = back.verify_attestations(&reg).unwrap();
assert_eq!(verified.len(), 1);
assert_eq!(verified[0].cert_hash, EXPECTED_CERT_HASH_HEX);
assert_eq!(verified[0].signer, EXPECTED_SIGNER_DID);
}
@@ -1,251 +0,0 @@
//! End-to-end integration with `gitlawb-core`.
//!
//! Proves the attest envelope composes with the real `RefUpdateCert` from
//! `gitlawb-core`: a cert built and signed through the core API wraps cleanly,
//! survives signing and verifying an attestation, and round-trips back to a
//! `RefUpdateCert` that core still verifies. None of this is exercised by the
//! unit tests in `cert.rs`, which use hand-written JSON literals to keep the
//! crate's runtime deps minimal.
use ed25519_dalek::SigningKey;
use gitlawb_attest::{
Attestation, AttestationPayload, AttestationVerifier, AttestedRefUpdateCert, Error, Policy,
Registry, Result as AttestResult,
};
use gitlawb_core::cert::RefUpdateCert;
use gitlawb_core::identity::Keypair;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
fn zero_hash() -> String {
"0".repeat(64)
}
fn commit_hash(c: char) -> String {
c.to_string().repeat(64)
}
fn fresh_signing_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct CovenantExecPayload {
agent: String,
capability: String,
sandbox_digest: String,
}
impl AttestationPayload for CovenantExecPayload {
fn payload_type() -> &'static str {
"covenant/exec/v1"
}
}
struct CovenantExecVerifier;
impl AttestationVerifier for CovenantExecVerifier {
fn type_(&self) -> &'static str {
"covenant/exec/v1"
}
fn verify_payload(&self, payload: &serde_json::Value) -> AttestResult<()> {
for field in ["agent", "capability", "sandbox_digest"] {
payload
.get(field)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::Payload(format!("missing or empty '{field}'")))?;
}
Ok(())
}
}
#[test]
fn wraps_real_cert_signs_and_verifies_attestation_then_unwraps_cleanly() {
// 1. Build a real cert through gitlawb-core. The pusher is the repo owner
// in this minimal setup; the cert is single-signature.
let pusher = Keypair::generate();
let repo_did = pusher.did();
let cert = RefUpdateCert::new(
repo_did,
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('a'),
1,
&pusher,
)
.unwrap();
cert.validate_structure().unwrap();
let valid_signers = cert.verify_all().unwrap();
assert_eq!(valid_signers.len(), 1);
// 2. Wrap. With no attestations attached the envelope is byte-identical to
// the underlying cert — that's the additive-on-the-wire promise.
let cert_json = serde_json::to_value(&cert).unwrap();
let mut env = AttestedRefUpdateCert::from_cert(&cert).unwrap();
assert_eq!(
serde_json::to_value(&env).unwrap(),
cert_json,
"empty envelope must serialize as the bare cert"
);
// 3. A separate party (here, an agent runtime) attests to how the commit
// was produced. The attestation signer is intentionally not the cert
// pusher: trust in the attestation is evaluated independently of trust
// in the push.
let agent_signer = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent".to_string(),
capability: "code.write+sandbox.exec".to_string(),
sandbox_digest: "sha256:c0ffee".to_string(),
};
let cert_hash = env.cert_hash().unwrap();
let attestation = Attestation::sign(&agent_signer, payload.clone(), cert_hash).unwrap();
env.attach(attestation);
// 4. Verify through a registry that knows `covenant/exec/v1`. Strict
// policy so an unknown discriminator would have been rejected.
let mut registry = Registry::new().with_policy(Policy::RejectUnknown);
registry.register(CovenantExecVerifier);
let verified = registry
.verify_all(&env.attestations, env.cert_hash().unwrap())
.unwrap();
assert_eq!(verified.len(), 1);
assert!(verified[0].fully_verified);
assert_eq!(verified[0].type_, "covenant/exec/v1");
assert_eq!(verified[0].cert_hash, hex::encode(cert_hash));
// 5. Round-trip through JSON and back. The wrapped cert with one
// attestation must parse back into the same envelope, and stripping
// the attestations must yield a `RefUpdateCert` core still verifies.
let on_wire = serde_json::to_string(&env).unwrap();
let back: AttestedRefUpdateCert = serde_json::from_str(&on_wire).unwrap();
assert_eq!(back.attestations.len(), 1);
assert_eq!(
back.attestations[0]
.payload_as::<CovenantExecPayload>()
.unwrap(),
payload
);
let mut unwrapped = back.cert.clone();
if let Some(obj) = unwrapped.as_object_mut() {
obj.remove("attestations");
}
let unwrapped_cert: RefUpdateCert = serde_json::from_value(unwrapped).unwrap();
let revalidated = unwrapped_cert.verify_all().unwrap();
assert_eq!(revalidated, valid_signers);
}
#[test]
fn cert_hash_is_stable_when_a_maintainer_countersigns() {
// The cert hash strips `signatures` before hashing, so countersignatures
// added after an attestation is signed must not invalidate the
// attestation's binding.
let pusher = Keypair::generate();
let maintainer = Keypair::generate();
let mut cert = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('b'),
7,
&pusher,
)
.unwrap();
let env_before = AttestedRefUpdateCert::from_cert(&cert).unwrap();
let hash_before = env_before.cert_hash().unwrap();
cert.countersign(&maintainer).unwrap();
let env_after = AttestedRefUpdateCert::from_cert(&cert).unwrap();
let hash_after = env_after.cert_hash().unwrap();
assert_eq!(
hash_before, hash_after,
"cert hash must be stable across countersignatures"
);
// An attestation signed against the pre-countersign hash still verifies
// against the post-countersign cert.
let agent = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent2".to_string(),
capability: "code.write".to_string(),
sandbox_digest: "sha256:deadbeef".to_string(),
};
let attestation = Attestation::sign(&agent, payload, hash_before).unwrap();
attestation.verify_signature(hash_after).unwrap();
}
#[test]
fn cross_cert_replay_is_rejected_against_a_real_cert() {
// Negative case: an attestation signed for one cert must not verify on a
// different cert built through the core API.
let pusher = Keypair::generate();
let cert_a = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('a'),
1,
&pusher,
)
.unwrap();
let cert_b = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
commit_hash('a'),
commit_hash('b'),
2,
&pusher,
)
.unwrap();
let env_a = AttestedRefUpdateCert::from_cert(&cert_a).unwrap();
let env_b = AttestedRefUpdateCert::from_cert(&cert_b).unwrap();
let hash_a = env_a.cert_hash().unwrap();
let hash_b = env_b.cert_hash().unwrap();
assert_ne!(hash_a, hash_b);
let agent = fresh_signing_key();
let payload = CovenantExecPayload {
agent: "did:key:z6MkAgent3".to_string(),
capability: "code.write".to_string(),
sandbox_digest: "sha256:cafe".to_string(),
};
let attestation = Attestation::sign(&agent, payload, hash_a).unwrap();
let err = attestation.verify_signature(hash_b).unwrap_err();
assert!(matches!(err, Error::CertHashMismatch));
}
/// A cert deserialized from JSON and a cert built through the core API must
/// hash identically. JCS canonicalization is what makes the additive-on-wire
/// promise hold across the serialize/deserialize boundary — without it, the
/// hash would depend on which side built the `serde_json::Value`.
#[test]
fn cert_hash_is_identical_across_the_deserialize_boundary() {
let pusher = Keypair::generate();
let cert = RefUpdateCert::new(
pusher.did(),
"refs/heads/main".to_string(),
zero_hash(),
commit_hash('c'),
9,
&pusher,
)
.unwrap();
let direct_hash = AttestedRefUpdateCert::from_cert(&cert)
.unwrap()
.cert_hash()
.unwrap();
let wire = serde_json::to_string(&cert).unwrap();
let parsed_value: serde_json::Value = serde_json::from_str(&wire).unwrap();
let parsed_env: AttestedRefUpdateCert = serde_json::from_value(parsed_value).unwrap();
let parsed_hash = parsed_env.cert_hash().unwrap();
assert_eq!(direct_hash, parsed_hash);
}
+14 -117
View File
@@ -10,9 +10,9 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::did::Did;
use crate::identity::{verify, Keypair};
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::{Keypair, verify};
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
@@ -110,7 +110,7 @@ impl RefUpdateCert {
///
/// Returns the list of DIDs whose signatures are valid.
pub fn verify_all(&self) -> Result<Vec<Did>> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let signing_bytes = self.body.to_signing_bytes()?;
let mut valid = Vec::new();
@@ -118,12 +118,10 @@ impl RefUpdateCert {
// Resolve the verifying key from the DID
let vk = cert_sig.signer.to_verifying_key()?;
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&cert_sig.sig)
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&cert_sig.sig)
.map_err(|e| Error::RefCert(format!("invalid base64 sig: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
.map_err(|_| Error::RefCert("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)?;
@@ -135,7 +133,11 @@ impl RefUpdateCert {
/// Check if this certificate satisfies a threshold of valid signatures
/// from the provided set of authorized maintainer DIDs.
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
pub fn satisfies_threshold(
&self,
maintainers: &[Did],
threshold: usize,
) -> Result<bool> {
let valid = self.verify_all()?;
let count = valid.iter().filter(|d| maintainers.contains(d)).count();
Ok(count >= threshold)
@@ -185,8 +187,7 @@ mod tests {
dummy_hash('a'),
1,
&kp,
)
.unwrap();
).unwrap();
cert.validate_structure().unwrap();
let valid = cert.verify_all().unwrap();
@@ -207,8 +208,7 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
).unwrap();
cert.countersign(&kp2).unwrap();
let valid = cert.verify_all().unwrap();
@@ -228,8 +228,7 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
)
.unwrap();
).unwrap();
cert.countersign(&kp2).unwrap();
let maintainers = vec![kp1.did(), kp2.did()];
@@ -247,8 +246,7 @@ mod tests {
dummy_hash('b'),
42,
&kp,
)
.unwrap();
).unwrap();
let json = serde_json::to_string_pretty(&cert).unwrap();
assert!(json.contains("gitlawb/ref-update/v1"));
@@ -257,105 +255,4 @@ mod tests {
// Round-trip
let _: RefUpdateCert = serde_json::from_str(&json).unwrap();
}
#[test]
fn validate_structure_rejects_wrong_cert_type() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.body.type_ = "gitlawb/ref-update/v0".to_string();
assert!(cert.validate_structure().is_err());
}
#[test]
fn validate_structure_rejects_invalid_to_hash() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.body.to = "not-a-valid-sha256".to_string();
assert!(cert.validate_structure().is_err());
}
#[test]
fn validate_structure_rejects_empty_signatures() {
let kp = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
cert.signatures.clear();
assert!(cert.validate_structure().is_err());
}
#[test]
fn tampered_signature_fails_verify_all() {
let kp = Keypair::generate();
let kp2 = Keypair::generate();
let mut cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
// Replace kp's signature with a signature from kp2 over different bytes.
// The signer DID still points to kp, so verification must fail.
cert.signatures[0].sig = kp2.sign_b64(b"unrelated bytes");
assert!(cert.verify_all().is_err());
}
#[test]
fn threshold_not_met_when_signer_outside_maintainer_list() {
let kp_pusher = Keypair::generate();
let kp_maintainer = Keypair::generate();
let cert = RefUpdateCert::new(
kp_pusher.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp_pusher,
)
.unwrap();
let maintainers = vec![kp_maintainer.did()];
assert!(!cert.satisfies_threshold(&maintainers, 1).unwrap());
}
#[test]
fn threshold_zero_is_always_satisfied() {
let kp = Keypair::generate();
let cert = RefUpdateCert::new(
kp.did(),
"refs/heads/main".to_string(),
dummy_hash('0'),
dummy_hash('a'),
1,
&kp,
)
.unwrap();
assert!(cert.satisfies_threshold(&[], 0).unwrap());
}
}
+5 -65
View File
@@ -11,7 +11,7 @@
use cid::CidGeneric;
use multihash_codetable::{Code, MultihashDigest};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sha2::{Sha256, Digest};
use std::fmt;
use crate::{Error, Result};
@@ -87,9 +87,9 @@ pub fn sha256_bytes(bytes: &[u8]) -> [u8; 32] {
/// Parse a 64-character hex SHA-256 string into raw bytes.
pub fn sha256_hex_to_bytes(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode(hex_str).map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes
.try_into()
let bytes = hex::decode(hex_str)
.map_err(|e| Error::InvalidCid(format!("invalid hex: {e}")))?;
bytes.try_into()
.map_err(|_| Error::InvalidCid("sha256 hash must be 32 bytes (64 hex chars)".to_string()))
}
@@ -110,10 +110,7 @@ mod tests {
// CIDv1 base32 strings start with 'b'
let data = b"blob 13\0hello gitlawb";
let c = Cid::from_git_object_bytes(data);
assert!(
c.to_string().starts_with('b'),
"CIDv1 should be base32 (starts with 'b')"
);
assert!(c.to_string().starts_with('b'), "CIDv1 should be base32 (starts with 'b')");
}
#[test]
@@ -129,61 +126,4 @@ mod tests {
let bytes = sha256_hex_to_bytes(&hex).unwrap();
assert_eq!(sha256_bytes(data), bytes);
}
#[test]
fn from_sha256_bytes_matches_object_bytes() {
// from_sha256_bytes must produce the same CID as from_git_object_bytes
// when given the SHA-256 of those bytes — the two construction paths
// must be equivalent for self-verifying content addressing to hold.
let data = b"blob 5\0hello";
let cid_from_object = Cid::from_git_object_bytes(data);
let hash = sha256_bytes(data);
let cid_from_hash = Cid::from_sha256_bytes(&hash);
assert_eq!(cid_from_object, cid_from_hash);
}
#[test]
fn from_str_parses_valid_cid() {
let data = b"tree content for test";
let cid = Cid::from_git_object_bytes(data);
let parsed = Cid::from_str(cid.as_str()).unwrap();
assert_eq!(cid, parsed);
}
#[test]
fn from_str_rejects_invalid_string() {
let result = Cid::from_str("not-a-valid-cid");
assert!(result.is_err());
}
#[test]
fn different_data_produces_different_cids() {
let c1 = Cid::from_git_object_bytes(b"blob 5\0hello");
let c2 = Cid::from_git_object_bytes(b"blob 5\0world");
assert_ne!(c1, c2);
}
#[test]
fn sha256_hex_to_bytes_rejects_non_hex() {
let non_hex = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
assert_eq!(non_hex.len(), 64);
let result = sha256_hex_to_bytes(non_hex);
assert!(result.is_err());
}
#[test]
fn sha256_hex_to_bytes_rejects_wrong_length() {
let result = sha256_hex_to_bytes("deadbeef");
assert!(result.is_err());
}
#[test]
fn sha256_hex_of_empty_input_is_well_known() {
// SHA-256("") is a fixed constant; verifies the hasher is wired correctly.
let h = sha256_hex(b"");
assert_eq!(
h,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}
+7 -67
View File
@@ -75,13 +75,12 @@ impl Did {
pub fn to_verifying_key(&self) -> Result<VerifyingKey> {
if !self.is_did_key() {
return Err(Error::InvalidDid(format!(
"expected did:key, got did:{}",
self.method()
"expected did:key, got did:{}", self.method()
)));
}
let (_, bytes) =
multibase::decode(self.method_id()).map_err(|e| Error::InvalidDid(e.to_string()))?;
let (_, bytes) = multibase::decode(self.method_id())
.map_err(|e| Error::InvalidDid(e.to_string()))?;
if !bytes.starts_with(ED25519_MULTICODEC) {
return Err(Error::InvalidDid(
@@ -93,7 +92,8 @@ impl Did {
.try_into()
.map_err(|_| Error::InvalidDid("ed25519 key must be 32 bytes".to_string()))?;
VerifyingKey::from_bytes(&key_bytes).map_err(|e| Error::InvalidDid(e.to_string()))
VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::InvalidDid(e.to_string()))
}
/// Return the full DID string as a `&str`.
@@ -105,9 +105,7 @@ impl Did {
pub fn validate(&self) -> Result<()> {
match self.method() {
"key" | "web" | "gitlawb" => Ok(()),
other => Err(Error::InvalidDid(format!(
"unsupported DID method: {other}"
))),
other => Err(Error::InvalidDid(format!("unsupported DID method: {other}"))),
}
}
}
@@ -123,9 +121,7 @@ impl FromStr for Did {
fn from_str(s: &str) -> Result<Self> {
if !s.starts_with("did:") {
return Err(Error::InvalidDid(format!(
"'{s}' does not start with 'did:'"
)));
return Err(Error::InvalidDid(format!("'{s}' does not start with 'did:'")));
}
let did = Self(s.to_string());
did.validate()?;
@@ -252,60 +248,4 @@ mod tests {
let json = serde_json::to_string_pretty(&doc).unwrap();
assert!(json.contains("Ed25519VerificationKey2020"));
}
#[test]
fn to_verifying_key_fails_for_did_web() {
let web = Did::web("example.com");
let result = web.to_verifying_key();
assert!(
result.is_err(),
"did:web cannot resolve to a verifying key locally"
);
}
#[test]
fn to_verifying_key_fails_for_did_gitlawb() {
let gl = Did::gitlawb("z6MkSomeKey");
let result = gl.to_verifying_key();
assert!(
result.is_err(),
"did:gitlawb cannot resolve to a verifying key locally"
);
}
#[test]
fn from_str_rejects_missing_did_prefix() {
let result = "key:z6MkABCD".parse::<Did>();
assert!(result.is_err());
}
#[test]
fn from_str_rejects_unsupported_method() {
let result = "did:ethr:0x1234abcd".parse::<Did>();
assert!(result.is_err());
}
#[test]
fn validate_accepts_all_supported_methods() {
Did::web("example.com").validate().unwrap();
Did::gitlawb("z6MkSomeKey").validate().unwrap();
Keypair::generate().did().validate().unwrap();
}
#[test]
fn is_did_key_and_is_did_gitlawb_predicates() {
let key_did = Keypair::generate().did();
assert!(key_did.is_did_key());
assert!(!key_did.is_did_gitlawb());
let gl = Did::gitlawb("z6MkSomeKey");
assert!(!gl.is_did_key());
assert!(gl.is_did_gitlawb());
}
#[test]
fn method_id_extraction() {
assert_eq!(Did::web("example.com").method_id(), "example.com");
assert_eq!(Did::gitlawb("z6MkSomeKey").method_id(), "z6MkSomeKey");
}
}
+25 -34
View File
@@ -11,14 +11,14 @@
//! Signature-Input: sig1=("@method" "@path" "content-digest");keyid="did:key:z6Mk...";alg="ed25519";created=<unix>
//! Signature: sig1=:base64signature:
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{Engine, engine::general_purpose::STANDARD};
use chrono::Utc;
use sha2::{Digest, Sha256};
use sha2::{Sha256, Digest};
use std::collections::HashMap;
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// The component identifiers covered by every gitlawb signature.
pub const COVERED_COMPONENTS: &[&str] = &["@method", "@path", "content-digest"];
@@ -53,16 +53,16 @@ impl HttpSignature {
let sig_input = sig_input.trim();
// Expect: sig1=("@method" "@path" "content-digest");keyid="...";alg="...";created=...
let rest = sig_input.strip_prefix("sig1=").ok_or_else(|| {
Error::HttpSignature("Signature-Input must start with 'sig1='".into())
})?;
let rest = sig_input
.strip_prefix("sig1=")
.ok_or_else(|| Error::HttpSignature("Signature-Input must start with 'sig1='".into()))?;
let open = rest
.find('(')
.ok_or_else(|| Error::HttpSignature("missing '(' in Signature-Input".into()))?;
let close = rest
.find(')')
.ok_or_else(|| Error::HttpSignature("missing ')' in Signature-Input".into()))?;
let open = rest.find('(').ok_or_else(|| {
Error::HttpSignature("missing '(' in Signature-Input".into())
})?;
let close = rest.find(')').ok_or_else(|| {
Error::HttpSignature("missing ')' in Signature-Input".into())
})?;
let components_str = &rest[open + 1..close];
let params_str = &rest[close + 1..]; // starts with ';'
@@ -98,19 +98,15 @@ impl HttpSignature {
.trim()
.strip_prefix("sig1=:")
.and_then(|s| s.strip_suffix(':'))
.ok_or_else(|| Error::HttpSignature("Signature must be 'sig1=:base64:'".into()))?;
.ok_or_else(|| {
Error::HttpSignature("Signature must be 'sig1=:base64:'".into())
})?;
let signature_bytes = STANDARD
.decode(sig_b64)
.map_err(|e| Error::HttpSignature(format!("invalid base64 in Signature: {e}")))?;
Ok(Self {
key_id,
alg,
created,
components,
signature_bytes,
})
Ok(Self { key_id, alg, created, components, signature_bytes })
}
/// Reject if the `created` timestamp is more than 5 minutes from now.
@@ -182,9 +178,12 @@ pub fn sign_request(
request_values.insert("@path".to_string(), path_and_query.to_string());
request_values.insert("content-digest".to_string(), content_digest.clone());
let signing_string =
build_signing_string(COVERED_COMPONENTS, sig_params_value, &request_values)
.expect("required components always present when building");
let signing_string = build_signing_string(
COVERED_COMPONENTS,
sig_params_value,
&request_values,
)
.expect("required components always present when building");
let sig_bytes = keypair.sign(signing_string.as_bytes());
let sig_b64 = STANDARD.encode(sig_bytes.to_bytes());
@@ -291,16 +290,10 @@ mod tests {
request_values.insert("content-digest".to_string(), headers.content_digest.clone());
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string =
build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let signing_string = build_signing_string(&components_ref, sig_params_value, &request_values).unwrap();
let vk = sig.key_id.to_verifying_key().unwrap();
let sig_b64 = headers
.signature
.strip_prefix("sig1=:")
.unwrap()
.strip_suffix(':')
.unwrap();
let sig_b64 = headers.signature.strip_prefix("sig1=:").unwrap().strip_suffix(':').unwrap();
let sig_bytes: [u8; 64] = STANDARD.decode(sig_b64).unwrap().try_into().unwrap();
assert!(verify(&vk, signing_string.as_bytes(), &sig_bytes).is_ok());
@@ -328,9 +321,7 @@ mod tests {
let kp = Keypair::generate();
let did = kp.did();
// created=1 is way in the past — should fail clock skew check
let sig_input = format!(
r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#
);
let sig_input = format!(r#"sig1=("@method" "@path" "content-digest");keyid="{did}";alg="ed25519";created=1"#);
let sig = HttpSignature::parse(&sig_input, "sig1=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:").unwrap();
assert!(sig.check_created().is_err());
}
+14 -75
View File
@@ -3,13 +3,13 @@
//! A gitlawb identity is an Ed25519 keypair. The public key encodes into a
//! `did:key` DID. The private key is stored as PKCS#8 PEM on disk.
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use ed25519_dalek::{SigningKey, VerifyingKey, Signer, Signature};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::did::Did;
use crate::{Error, Result};
use crate::did::Did;
/// An Ed25519 keypair that is the root identity for a gitlawb actor.
#[derive(Clone)]
@@ -47,7 +47,7 @@ impl Keypair {
/// Sign and return base64url-encoded signature string.
pub fn sign_b64(&self, msg: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let sig = self.sign(msg);
URL_SAFE_NO_PAD.encode(sig.to_bytes())
}
@@ -69,13 +69,18 @@ impl Keypair {
/// Load from PEM-encoded PKCS#8 private key string.
pub fn from_pem(pem: &str) -> Result<Self> {
use pkcs8::DecodePrivateKey;
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|e| Error::Key(e.to_string()))?;
let signing_key = SigningKey::from_pkcs8_pem(pem)
.map_err(|e| Error::Key(e.to_string()))?;
Ok(Self { signing_key })
}
}
/// Verify an Ed25519 signature.
pub fn verify(verifying_key: &VerifyingKey, msg: &[u8], sig_bytes: &[u8; 64]) -> Result<()> {
pub fn verify(
verifying_key: &VerifyingKey,
msg: &[u8],
sig_bytes: &[u8; 64],
) -> Result<()> {
use ed25519_dalek::Verifier;
let sig = Signature::from_bytes(sig_bytes);
verifying_key
@@ -112,8 +117,8 @@ impl<T: Serialize> Signed<T> {
}
mod sig_b64 {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Deserializer, Serializer};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserializer, Serializer, Deserialize};
pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
@@ -121,12 +126,8 @@ mod sig_b64 {
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
let s = String::deserialize(d)?;
let bytes = URL_SAFE_NO_PAD
.decode(&s)
.map_err(serde::de::Error::custom)?;
bytes
.try_into()
.map_err(|_| serde::de::Error::custom("expected 64 bytes"))
let bytes = URL_SAFE_NO_PAD.decode(&s).map_err(serde::de::Error::custom)?;
bytes.try_into().map_err(|_| serde::de::Error::custom("expected 64 bytes"))
}
}
@@ -157,66 +158,4 @@ mod tests {
let did = kp.did();
assert!(did.to_string().starts_with("did:key:z6Mk"));
}
#[test]
fn from_seed_round_trip() {
let kp = Keypair::generate();
let seed = kp.to_seed();
let kp2 = Keypair::from_seed(&seed).unwrap();
assert_eq!(kp.verifying_key(), kp2.verifying_key());
}
#[test]
fn sign_b64_decodes_to_valid_signature() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let kp = Keypair::generate();
let msg = b"test payload for b64 signing";
let b64 = kp.sign_b64(msg);
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
.decode(&b64)
.expect("sign_b64 must produce valid base64url")
.try_into()
.expect("Ed25519 signature must be 64 bytes");
verify(&kp.verifying_key(), msg, &sig_bytes).unwrap();
}
#[test]
fn verify_rejects_wrong_key() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let msg = b"some message";
let sig = kp1.sign(msg);
let result = verify(&kp2.verifying_key(), msg, &sig.to_bytes());
assert!(
result.is_err(),
"signature from kp1 must not verify under kp2"
);
}
#[test]
fn signed_payload_round_trip() {
let kp = Keypair::generate();
let payload = serde_json::json!({"action": "push", "repo": "my-repo"});
let signed = Signed::new(payload, &kp).unwrap();
signed.verify(&kp.verifying_key()).unwrap();
}
#[test]
fn signed_payload_wrong_key_fails() {
let kp1 = Keypair::generate();
let kp2 = Keypair::generate();
let payload = serde_json::json!({"action": "push"});
let signed = Signed::new(payload, &kp1).unwrap();
let result = signed.verify(&kp2.verifying_key());
assert!(
result.is_err(),
"Signed payload must not verify under a different key"
);
}
#[test]
fn from_pem_invalid_input_fails() {
let result = Keypair::from_pem("this is not valid PEM at all");
assert!(result.is_err());
}
}
+4 -4
View File
@@ -1,10 +1,10 @@
pub mod cert;
pub mod cid;
pub mod did;
pub mod error;
pub mod http_sig;
pub mod cid;
pub mod identity;
pub mod http_sig;
pub mod ucan;
pub mod cert;
pub mod error;
pub use error::Error;
pub type Result<T> = std::result::Result<T, Error>;
+23 -247
View File
@@ -12,9 +12,9 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
use crate::did::Did;
use crate::identity::Keypair;
use crate::{Error, Result};
/// A UCAN capability: what resource the token grants access to.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -30,31 +30,13 @@ pub struct Capability {
impl Capability {
pub fn new(with: impl Into<String>, can: impl Into<String>) -> Self {
Self {
with: with.into(),
can: can.into(),
constraints: None,
}
Self { with: with.into(), can: can.into(), constraints: None }
}
pub fn with_constraints(mut self, constraints: serde_json::Value) -> Self {
self.constraints = Some(constraints);
self
}
/// Returns `true` if `self` is a valid attenuation of `parent`.
///
/// A delegated capability is only valid if it is at most as permissive as
/// the parent capability backing it. `"*"` on the **parent**'s resource or
/// action field and `repo/admin` in the parent's action position act as
/// wildcards that cover any delegated value; wildcards on `self` carry no
/// special meaning.
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
let resource_ok = parent.with == self.with || parent.with == "*";
let action_ok =
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
resource_ok && action_ok
}
}
/// Well-known gitlawb capability strings.
@@ -146,40 +128,18 @@ impl Ucan {
}
}
/// Check if this UCAN's not-before time is in the future (token not yet valid).
pub fn is_before_valid(&self) -> bool {
if let Some(nbf) = self.payload.nbf {
Utc::now().timestamp() < nbf
} else {
false
}
}
/// Verify this UCAN's audience matches `expected`.
pub fn verify_audience(&self, expected: &Did) -> Result<()> {
if &self.payload.aud != expected {
return Err(Error::Ucan(format!(
"audience mismatch: expected {expected}, got {}",
self.payload.aud
)));
}
Ok(())
}
/// Verify the signature on this UCAN.
pub fn verify_signature(&self) -> Result<()> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use crate::identity::verify;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let vk = self.payload.iss.to_verifying_key()?;
let signing_bytes = serde_json::to_vec(&self.payload)?;
let sig_bytes_vec = URL_SAFE_NO_PAD
.decode(&self.s)
let sig_bytes_vec = URL_SAFE_NO_PAD.decode(&self.s)
.map_err(|e| Error::Ucan(format!("invalid base64 signature: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into()
.map_err(|_| Error::Ucan("signature must be 64 bytes".to_string()))?;
verify(&vk, &signing_bytes, &sig_bytes)
@@ -188,10 +148,9 @@ impl Ucan {
/// Check if this UCAN grants a specific capability on a resource.
pub fn can(&self, resource: &str, action: &str) -> bool {
self.payload
.att
.iter()
.any(|cap| cap.with == resource && cap.can == action)
self.payload.att.iter().any(|cap| {
cap.with == resource && cap.can == action
})
}
/// Encode to a compact JSON string (the wire format).
@@ -251,10 +210,6 @@ impl Ucan {
return Err(Error::Ucan("token is expired".to_string()));
}
if self.is_before_valid() {
return Err(Error::Ucan("token is not yet valid".to_string()));
}
for proof_token in &self.payload.prf {
let proof = Self::decode(proof_token)
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
@@ -267,17 +222,6 @@ impl Ucan {
)));
}
// Every delegated capability must be covered by the proof (attenuation).
for cap in &self.payload.att {
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
if !covered {
return Err(Error::Ucan(format!(
"capability attenuation violated: '{}' on '{}' not covered by proof",
cap.can, cap.with
)));
}
}
// Verify the proof's signature and chain recursively
proof.verify_chain()?;
}
@@ -301,8 +245,7 @@ mod tests {
audience.clone(),
vec![Capability::new("gitlawb://repos/test/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
ucan.verify_signature().unwrap();
assert!(!ucan.is_expired());
@@ -348,12 +291,10 @@ mod tests {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(
&issuer,
audience,
&issuer, audience,
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Root UCAN (no proofs) should verify fine
ucan.verify_chain().unwrap();
}
@@ -366,22 +307,18 @@ mod tests {
// Alice grants Bob push access
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Bob delegates to Charlie (with proof from Alice)
let delegated = Ucan::delegate(
&bob,
charlie.did(),
&bob, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Chain should verify: Charlie's token → Bob's proof → Alice signed it
delegated.verify_chain().unwrap();
@@ -397,22 +334,18 @@ mod tests {
// Alice grants Bob access
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
)
.unwrap();
).unwrap();
// Eve (NOT Bob) tries to delegate using Alice's proof
let bad = Ucan::delegate(
&eve,
charlie.did(),
&eve, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Should fail: proof audience (Bob) != UCAN issuer (Eve)
let err = bad.verify_chain().unwrap_err();
@@ -428,177 +361,20 @@ mod tests {
// Alice grants Bob access with expiry in the past
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
let root = Ucan::issue(
&alice,
bob.did(),
&alice, bob.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
Some(exp),
)
.unwrap();
).unwrap();
let delegated = Ucan::delegate(
&bob,
charlie.did(),
&bob, charlie.did(),
vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
).unwrap();
// Should fail: the proof is expired
let err = delegated.verify_chain().unwrap_err();
assert!(err.to_string().contains("expired"));
}
#[test]
fn is_before_valid_future_nbf() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let nbf_future = chrono::Utc::now() + chrono::Duration::hours(1);
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: issuer.did(),
aud: audience,
att: vec![],
exp: None,
nbf: Some(nbf_future.timestamp()),
prf: vec![],
};
let signing_bytes = serde_json::to_vec(&payload).unwrap();
let sig = issuer.sign_b64(&signing_bytes);
let ucan = Ucan { payload, s: sig };
assert!(ucan.is_before_valid());
let err = ucan.verify_chain().unwrap_err();
assert!(err.to_string().contains("not yet valid"));
}
#[test]
fn is_before_valid_past_nbf() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let nbf_past = chrono::Utc::now() - chrono::Duration::hours(1);
let payload = UcanPayload {
ucan: "1.0.0".to_string(),
iss: issuer.did(),
aud: audience,
att: vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
exp: None,
nbf: Some(nbf_past.timestamp()),
prf: vec![],
};
let signing_bytes = serde_json::to_vec(&payload).unwrap();
let sig = issuer.sign_b64(&signing_bytes);
let ucan = Ucan { payload, s: sig };
assert!(!ucan.is_before_valid());
ucan.verify_chain().unwrap();
}
#[test]
fn verify_audience_matches() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let ucan = Ucan::issue(&issuer, audience.clone(), vec![], None).unwrap();
ucan.verify_audience(&audience).unwrap();
}
#[test]
fn verify_audience_mismatch() {
let issuer = Keypair::generate();
let audience = Keypair::generate().did();
let wrong = Keypair::generate().did();
let ucan = Ucan::issue(&issuer, audience, vec![], None).unwrap();
let err = ucan.verify_audience(&wrong).unwrap_err();
assert!(err.to_string().contains("audience mismatch"));
}
#[test]
fn attenuation_valid_subset() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob push on a specific repo
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
// Bob delegates the same capability (exact subset) to Charlie
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
delegated.verify_chain().unwrap();
}
#[test]
fn attenuation_exceeds_parent_is_rejected() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob push on one repo only
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
)
.unwrap();
// Bob tries to delegate merge (not in the original grant) to Charlie
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::PR_MERGE)],
None,
&root,
)
.unwrap();
let err = delegated.verify_chain().unwrap_err();
assert!(err.to_string().contains("attenuation violated"));
}
#[test]
fn attenuation_repo_admin_covers_all() {
let alice = Keypair::generate();
let bob = Keypair::generate();
let charlie = Keypair::generate();
// Alice grants Bob repo/admin (superpower)
let root = Ucan::issue(
&alice,
bob.did(),
vec![Capability::new(
"gitlawb://repos/org/repo",
caps::REPO_ADMIN,
)],
None,
)
.unwrap();
// Bob delegates a more specific capability — covered by repo/admin
let delegated = Ucan::delegate(
&bob,
charlie.did(),
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
None,
&root,
)
.unwrap();
delegated.verify_chain().unwrap();
}
}
+2 -14
View File
@@ -37,24 +37,13 @@ http-body-util = "0.1"
tokio-util = { version = "0.7", features = ["io"] }
dirs-next = "2"
reqwest = { workspace = true }
libp2p-core = "0.43"
libp2p-gossipsub = "0.49.4"
libp2p-identify = "0.47"
libp2p-identity = { version = "0.2", features = ["ed25519", "peerid", "rand"] }
libp2p-kad = "0.48"
libp2p-quic = { version = "0.13", features = ["tokio"] }
libp2p-swarm = { version = "0.47", features = ["macros", "tokio"] }
multiaddr = "0.18"
libp2p = { version = "0.55", features = ["gossipsub", "kad", "mdns", "noise", "tcp", "tokio", "yamux", "identify", "macros"] }
futures = "0.3"
aws-sdk-s3 = { version = "1", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
aws-sdk-s3 = "1"
aws-config = { version = "1", features = ["behavior-version-latest"] }
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
tar = "0.4"
zstd = "0.13"
# Prometheus metrics. Used to expose a /metrics endpoint for ops/observability
# on the opt-in GITLAWB_METRICS_ADDR listener. The crate is also the de-facto
# exposition format encoder in the Rust ecosystem.
prometheus = { version = "0.13", default-features = false }
alloy = { version = "1", default-features = false, features = [
"contract",
"provider-http",
@@ -65,7 +54,6 @@ alloy = { version = "1", default-features = false, features = [
"network",
"rpc-types-eth",
] }
libp2p-dns = { version = "0.44.0", features = ["tokio"] }
[dev-dependencies]
mockito = "1"
+19 -84
View File
@@ -8,37 +8,6 @@ use serde::{Deserialize, Serialize};
use crate::error::{AppError, Result};
use crate::state::AppState;
fn normalize_agent_did(did: &str) -> String {
if did.starts_with("did:") {
did.to_string()
} else {
format!("did:key:{did}")
}
}
fn agent_key_segment(did: &str) -> &str {
did.split(':').next_back().unwrap_or(did)
}
async fn resolve_agent_did(state: &AppState, did: &str) -> Result<String> {
let normalized_did = normalize_agent_did(did);
if state.db.get_agent(&normalized_did).await?.is_some() {
return Ok(normalized_did);
}
let requested_key = agent_key_segment(&normalized_did);
let matching_agent = state
.db
.list_agents(None)
.await?
.into_iter()
.find(|agent| agent_key_segment(&agent.did).starts_with(requested_key));
Ok(matching_agent
.map(|agent| agent.did)
.unwrap_or(normalized_did))
}
#[derive(Debug, Serialize)]
pub struct TrustResponse {
pub did: String,
@@ -79,16 +48,13 @@ pub async fn list_agents(
Query(params): Query<AgentListQuery>,
) -> Result<Json<serde_json::Value>> {
let agents = state.db.list_agents(params.capability.as_deref()).await?;
let list: Vec<AgentResponse> = agents
.into_iter()
.map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
})
.collect();
let list: Vec<AgentResponse> = agents.into_iter().map(|a| AgentResponse {
did: a.did,
trust_score: a.trust_score,
capabilities: a.capabilities,
registered_at: a.registered_at,
last_seen: a.last_seen,
}).collect();
Ok(Json(serde_json::json!({ "agents": list })))
}
@@ -97,22 +63,15 @@ pub async fn show_agent(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<(StatusCode, Json<AgentResponse>)> {
let normalized_did = resolve_agent_did(&state, &did).await?;
let agent = state
.db
.get_agent(&normalized_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {normalized_did} not found")))?;
Ok((
StatusCode::OK,
Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
}),
))
let agent = state.db.get_agent(&did).await?
.ok_or_else(|| AppError::NotFound(format!("agent {did} not found")))?;
Ok((StatusCode::OK, Json(AgentResponse {
did: agent.did,
trust_score: agent.trust_score,
capabilities: agent.capabilities,
registered_at: agent.registered_at,
last_seen: agent.last_seen,
})))
}
/// GET /api/v1/agents/{did}/trust
@@ -120,38 +79,14 @@ pub async fn get_trust(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<TrustResponse>> {
let normalized_did = resolve_agent_did(&state, &did).await?;
let trust_score = state.db.get_trust_score(&normalized_did).await?;
let push_count = state.db.get_push_count(&normalized_did).await?;
let trust_score = state.db.get_trust_score(&did).await?;
let push_count = state.db.get_push_count(&did).await?;
let level = trust_level(trust_score);
Ok(Json(TrustResponse {
did: normalized_did,
did,
trust_score,
push_count,
level,
}))
}
#[cfg(test)]
mod tests {
use super::{agent_key_segment, normalize_agent_did};
#[test]
fn normalize_agent_did_preserves_full_did() {
let did = "did:key:z6MkExample";
assert_eq!(normalize_agent_did(did), did);
}
#[test]
fn normalize_agent_did_expands_bare_key() {
assert_eq!(normalize_agent_did("z6MkExample"), "did:key:z6MkExample");
}
#[test]
fn agent_key_segment_extracts_did_key_material() {
assert_eq!(agent_key_segment("did:key:z6MkExample"), "z6MkExample");
assert_eq!(agent_key_segment("z6MkExample"), "z6MkExample");
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ pub async fn list_anchors(
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await
.map_err(crate::error::AppError::Internal)?;
.map_err(|e| crate::error::AppError::Internal(e.into()))?;
Ok(Json(serde_json::json!({
"anchors": anchors,
+45 -119
View File
@@ -80,10 +80,7 @@ pub async fn create_bounty(
}
// Verify repo exists
let _ = state
.db
.get_repo(&owner, &repo)
.await?
let _ = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let now = Utc::now().to_rfc3339();
@@ -119,15 +116,12 @@ pub async fn list_repo_bounties(
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<ListBountiesQuery>,
) -> Result<Json<serde_json::Value>> {
let bounties = state
.db
.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
)
.await?;
let bounties = state.db.list_bounties(
Some(&owner),
Some(&repo),
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
@@ -137,10 +131,12 @@ pub async fn list_all_bounties(
State(state): State<AppState>,
Query(q): Query<ListBountiesQuery>,
) -> Result<Json<serde_json::Value>> {
let bounties = state
.db
.list_bounties(None, None, q.status.as_deref(), q.limit.unwrap_or(50))
.await?;
let bounties = state.db.list_bounties(
None,
None,
q.status.as_deref(),
q.limit.unwrap_or(50),
).await?;
Ok(Json(serde_json::json!({ "bounties": bounties })))
}
@@ -150,10 +146,7 @@ pub async fn get_bounty(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(bounty))
}
@@ -165,31 +158,19 @@ pub async fn claim_bounty(
Path(id): Path<String>,
Json(req): Json<ClaimBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not open",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not open", bounty.status)));
}
let now = Utc::now().to_rfc3339();
state
.db
.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now)
.await?;
state.db.claim_bounty(&id, &auth.0, req.wallet.as_deref(), &now).await?;
tracing::info!(bounty_id = %id, agent = %auth.0, "bounty claimed");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -201,17 +182,11 @@ pub async fn submit_bounty(
Path(id): Path<String>,
Json(req): Json<SubmitBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not claimed",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not claimed", bounty.status)));
}
if bounty.claimant_did.as_deref() != Some(&auth.0) {
return Err(AppError::BadRequest("only the claimant can submit".into()));
@@ -222,10 +197,7 @@ pub async fn submit_bounty(
tracing::info!(bounty_id = %id, pr_id = %req.pr_id, "bounty submission");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -237,29 +209,18 @@ pub async fn approve_bounty(
Path(id): Path<String>,
Json(req): Json<ApproveBountyRequest>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
"bounty is {}, not submitted",
bounty.status
)));
return Err(AppError::BadRequest(format!("bounty is {}, not submitted", bounty.status)));
}
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest(
"only the bounty creator can approve".into(),
));
return Err(AppError::BadRequest("only the bounty creator can approve".into()));
}
let now = Utc::now().to_rfc3339();
state
.db
.approve_bounty(&id, &now, req.tx_hash.as_deref())
.await?;
state.db.approve_bounty(&id, &now, req.tx_hash.as_deref()).await?;
// Bump claimant trust score
if let Some(ref agent_did) = bounty.claimant_did {
@@ -270,10 +231,7 @@ pub async fn approve_bounty(
tracing::info!(bounty_id = %id, "bounty approved");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -284,31 +242,20 @@ pub async fn cancel_bounty(
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "open" {
return Err(AppError::BadRequest(format!(
"can only cancel open bounties, status is {}",
bounty.status
)));
return Err(AppError::BadRequest(format!("can only cancel open bounties, status is {}", bounty.status)));
}
if bounty.creator_did != auth.0 {
return Err(AppError::BadRequest(
"only the bounty creator can cancel".into(),
));
return Err(AppError::BadRequest("only the bounty creator can cancel".into()));
}
state.db.cancel_bounty(&id).await?;
tracing::info!(bounty_id = %id, "bounty cancelled");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
@@ -316,20 +263,13 @@ pub async fn cancel_bounty(
/// POST /api/v1/bounties/{id}/dispute
pub async fn dispute_bounty(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path(id): Path<String>,
) -> Result<Json<BountyRecord>> {
let bounty = state
.db
.get_bounty(&id)
.await?
let bounty = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
if bounty.status != "claimed" && bounty.status != "submitted" {
return Err(AppError::BadRequest(format!(
"can only dispute claimed/submitted bounties, status is {}",
bounty.status
)));
return Err(AppError::BadRequest(format!("can only dispute claimed/submitted bounties, status is {}", bounty.status)));
}
// Check if deadline exceeded
@@ -337,47 +277,33 @@ pub async fn dispute_bounty(
if let Ok(claimed) = chrono::DateTime::parse_from_rfc3339(claimed_at) {
let deadline = claimed + chrono::Duration::seconds(bounty.deadline_secs);
if Utc::now() < deadline {
return Err(AppError::BadRequest(
"deadline has not been exceeded yet".into(),
));
return Err(AppError::BadRequest("deadline has not been exceeded yet".into()));
}
}
}
state.db.dispute_bounty(&id).await?;
tracing::info!(bounty_id = %id, actor = %auth.0, "bounty disputed - reopened");
tracing::info!(bounty_id = %id, "bounty disputed — reopened");
let updated = state
.db
.get_bounty(&id)
.await?
let updated = state.db.get_bounty(&id).await?
.ok_or_else(|| AppError::NotFound(format!("bounty {id} not found")))?;
Ok(Json(updated))
}
/// GET /api/v1/bounties/stats
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
pub async fn bounty_stats(
State(state): State<AppState>,
) -> Result<Json<BountyStatsResponse>> {
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
let claimed = state
.db
.count_bounties_by_status("claimed")
.await
.unwrap_or(0);
let completed = state
.db
.count_bounties_by_status("completed")
.await
.unwrap_or(0);
let claimed = state.db.count_bounties_by_status("claimed").await.unwrap_or(0);
let completed = state.db.count_bounties_by_status("completed").await.unwrap_or(0);
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
let leaderboard = leaders
.into_iter()
.map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
})
.collect();
let leaderboard = leaders.into_iter().map(|(did, cnt, total)| AgentBountyEntry {
did,
completed: cnt,
total_earned: total,
}).collect();
Ok(Json(BountyStatsResponse {
open,
+14 -28
View File
@@ -11,29 +11,21 @@ pub async fn list_certs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let certs = state.db.list_ref_certificates(&record.id).await?;
let certs_json: Vec<serde_json::Value> = certs
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})
})
.collect();
let certs_json: Vec<serde_json::Value> = certs.iter().map(|c| serde_json::json!({
"id": c.id,
"repo_id": c.repo_id,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"signature": c.signature,
"issued_at": c.issued_at,
})).collect();
Ok(Json(serde_json::json!({ "certificates": certs_json })))
}
@@ -44,16 +36,10 @@ pub async fn get_cert(
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
// Verify the repo exists
let _record = state
.db
.get_repo(&owner, &name)
.await?
let _record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let cert = state
.db
.get_ref_certificate(&id)
.await?
let cert = state.db.get_ref_certificate(&id).await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))?;
Ok(Json(serde_json::json!({
+12 -23
View File
@@ -14,9 +14,7 @@ pub struct ChangelogQuery {
pub limit: usize,
}
fn default_limit() -> usize {
20
}
fn default_limit() -> usize { 20 }
/// GET /api/v1/repos/:owner/:repo/changelog[?limit=N]
///
@@ -28,36 +26,27 @@ pub async fn get_changelog(
Path((owner, repo)): Path<(String, String)>,
Query(query): Query<ChangelogQuery>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let limit = query.limit.min(100);
// ── Commits from git log ─────────────────────────────────────────────
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let head_ref = store::resolve_head(&disk_path, &record.default_branch);
let commits = store::log(&disk_path, &head_ref, limit).unwrap_or_default();
let mut events: Vec<serde_json::Value> = commits
.into_iter()
.map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
})
let mut events: Vec<serde_json::Value> = commits.into_iter().map(|c| {
serde_json::json!({
"type": "commit",
"sha": c.hash,
"message": c.subject,
"author": c.author_name,
"timestamp": c.timestamp,
"branch": record.default_branch,
})
.collect();
}).collect();
// ── Merged PRs ───────────────────────────────────────────────────────
let prs = state.db.list_prs(&record.id).await.unwrap_or_default();
+48 -72
View File
@@ -13,36 +13,28 @@ pub async fn list_ref_updates(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>> {
let limit = params
.get("limit")
let limit = params.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(50)
.min(200);
let updates = state.db.list_ref_updates(limit).await?;
let events: Vec<serde_json::Value> = updates
.iter()
.map(|u| {
serde_json::json!({
"id": u.id,
"node_did": u.node_did,
"pusher_did": u.pusher_did,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
})
})
.collect();
let events: Vec<serde_json::Value> = updates.iter().map(|u| serde_json::json!({
"id": u.id,
"node_did": u.node_did,
"pusher_did": u.pusher_did,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
})).collect();
let count = events.len();
Ok(Json(
serde_json::json!({ "events": events, "count": count }),
))
Ok(Json(serde_json::json!({ "events": events, "count": count })))
}
/// GET /api/v1/repos/{owner}/{repo}/events
@@ -51,8 +43,7 @@ pub async fn list_repo_events(
Path((owner, repo_name)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>> {
let limit = params
.get("limit")
let limit = params.get("limit")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(50)
.min(200);
@@ -67,11 +58,7 @@ pub async fn list_repo_events(
let repo_id_str = if let Some(ref record) = repo_record {
format!(
"{}/{}",
record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did),
record.owner_did.split(':').last().unwrap_or(&record.owner_did),
repo_name
)
} else {
@@ -80,55 +67,46 @@ pub async fn list_repo_events(
// Fetch local ref certificates for this repo (if the repo exists on this node)
let cert_events: Vec<serde_json::Value> = if let Some(ref record) = repo_record {
state
.db
.list_ref_certificates(&record.id)
.await
state.db.list_ref_certificates(&record.id).await
.unwrap_or_default()
.iter()
.map(|c| {
serde_json::json!({
"type": "local_cert",
"id": c.id,
"repo": repo_id_str,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"timestamp": c.issued_at,
"source": "local",
})
})
.map(|c| serde_json::json!({
"type": "local_cert",
"id": c.id,
"repo": repo_id_str,
"ref_name": c.ref_name,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"pusher_did": c.pusher_did,
"node_did": c.node_did,
"timestamp": c.issued_at,
"source": "local",
}))
.collect()
} else {
vec![]
};
// Fetch gossipsub received ref updates for this repo (uses full slug built above)
let gossip_events: Vec<serde_json::Value> = state
.db
.list_repo_ref_updates(&repo_id_str, limit)
.await
let gossip_events: Vec<serde_json::Value> = state.db
.list_repo_ref_updates(&repo_id_str, limit).await
.unwrap_or_default()
.iter()
.map(|u| {
serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"source": "gossipsub",
})
})
.map(|u| serde_json::json!({
"type": "gossipsub",
"id": u.id,
"repo": u.repo,
"ref_name": u.ref_name,
"old_sha": u.old_sha,
"new_sha": u.new_sha,
"pusher_did": u.pusher_did,
"node_did": u.node_did,
"timestamp": u.timestamp,
"cert_id": u.cert_id,
"received_at": u.received_at,
"from_peer": u.from_peer,
"source": "gossipsub",
}))
.collect();
// Merge both lists
@@ -146,7 +124,5 @@ pub async fn list_repo_events(
all_events.truncate(limit as usize);
let count = all_events.len();
Ok(Json(
serde_json::json!({ "events": all_events, "count": count }),
))
Ok(Json(serde_json::json!({ "events": all_events, "count": count })))
}
+7 -7
View File
@@ -50,9 +50,8 @@ pub async fn get_by_cid(
// 2. Search all repos for an object with this SHA-256
let repos = state
.db
.list_all_repos()
.await
.map_err(AppError::Internal)?;
.list_all_repos().await
.map_err(|e| AppError::Internal(e.into()))?;
for repo in &repos {
let repo_path = match state.repo_store.acquire(&repo.owner_did, &repo.name).await {
@@ -100,12 +99,13 @@ pub async fn get_by_cid(
/// Returns all CIDs that have been pinned to the local IPFS node from git
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
pub async fn list_pins(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>> {
let pins = state
.db
.list_pinned_cids()
.await
.map_err(AppError::Internal)?;
.list_pinned_cids().await
.map_err(|e| AppError::Internal(e.into()))?;
Ok(Json(serde_json::json!({
"pins": pins,
+19 -54
View File
@@ -10,8 +10,8 @@ use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::IssueComment;
use crate::error::{AppError, Result};
use crate::git::issues as git_issues;
use crate::state::AppState;
use crate::git::issues as git_issues;
#[derive(Debug, Deserialize)]
pub struct CreateIssueRequest {
@@ -39,10 +39,7 @@ pub async fn create_issue(
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<CreateIssueRequest>,
) -> Result<(StatusCode, Json<IssueRecord>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let issue_id = Uuid::new_v4().to_string();
@@ -61,17 +58,14 @@ pub async fn create_issue(
let json_str = serde_json::to_string(&issue)
.map_err(|e| AppError::BadRequest(format!("serialization error: {e}")))?;
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let create_result = git_issues::create_issue(&disk_path, &issue_id, &json_str);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(create_result.is_ok()).await;
// Always release the advisory lock — even on error
guard.release().await;
create_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -91,20 +85,14 @@ pub async fn list_issues(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let raw_issues =
git_issues::list_issues(&disk_path).map_err(|e| AppError::Git(e.to_string()))?;
let raw_issues = git_issues::list_issues(&disk_path)
.map_err(|e| AppError::Git(e.to_string()))?;
let mut issues: Vec<serde_json::Value> = Vec::new();
for raw in raw_issues {
@@ -121,16 +109,10 @@ pub async fn get_issue(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let raw = git_issues::get_issue(&disk_path, &issue_id)
@@ -156,21 +138,13 @@ pub async fn create_issue_comment(
Json(req): Json<CreateIssueCommentRequest>,
) -> Result<(StatusCode, Json<IssueComment>)> {
if req.body.trim().is_empty() {
return Err(AppError::BadRequest(
"comment body must not be empty".into(),
));
return Err(AppError::BadRequest("comment body must not be empty".into()));
}
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
// Verify issue exists
crate::git::issues::get_issue(&disk_path, &issue_id)
@@ -194,10 +168,7 @@ pub async fn list_issue_comments(
State(state): State<AppState>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let _record = state
.db
.get_repo(&owner, &repo)
.await?
let _record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let comments = state.db.list_issue_comments(&issue_id).await?;
@@ -210,23 +181,17 @@ pub async fn close_issue(
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, repo, issue_id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let close_result = git_issues::close_issue(&disk_path, &issue_id);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(close_result.is_ok()).await;
// Always release the advisory lock — even on error
guard.release().await;
let updated = close_result
.map_err(|e| AppError::Git(e.to_string()))?
+6 -25
View File
@@ -25,31 +25,18 @@ pub async fn add_label(
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
if !label
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == ':')
{
if !label.chars().all(|c| c.is_alphanumeric() || c == '-' || c == ':') {
return Err(AppError::BadRequest(
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let added = state.db.add_label(&record.id, &label).await?;
let status = if added {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((
status,
Json(serde_json::json!({ "label": label, "added": added })),
))
let status = if added { StatusCode::CREATED } else { StatusCode::OK };
Ok((status, Json(serde_json::json!({ "label": label, "added": added }))))
}
/// DELETE /api/v1/repos/:owner/:repo/labels/:label
@@ -58,10 +45,7 @@ pub async fn remove_label(
Extension(_auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
state.db.remove_label(&record.id, &label).await?;
@@ -73,10 +57,7 @@ pub async fn list_labels(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let labels = state.db.list_labels(&record.id).await?;
-3
View File
@@ -8,14 +8,11 @@ pub mod ipfs;
pub mod issues;
pub mod labels;
pub mod peers;
pub mod profiles;
pub mod protect;
pub mod pulls;
pub mod register;
pub mod replicas;
pub mod repos;
pub mod resolve;
pub mod stars;
pub mod tasks;
pub mod visibility;
pub mod webhooks;
+41 -149
View File
@@ -10,13 +10,11 @@
//! POST /api/v1/sync/notify — receive push notification from a peer node
//! POST /api/v1/sync/trigger — manually pull all repos from known peers
use axum::extract::{Extension, Path, State};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use gitlawb_core::did::Did;
use serde::{Deserialize, Serialize};
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
@@ -52,9 +50,7 @@ pub async fn list_peers(State(state): State<AppState>) -> Result<Json<serde_json
reachable: p.last_ping_ok,
})
.collect();
Ok(Json(
serde_json::json!({ "peers": resp, "count": resp.len() }),
))
Ok(Json(serde_json::json!({ "peers": resp, "count": resp.len() })))
}
/// POST /api/v1/peers/announce (auth required)
@@ -63,48 +59,11 @@ pub async fn list_peers(State(state): State<AppState>) -> Result<Json<serde_json
/// The Authorization header's DID must match the `did` in the body.
pub async fn announce(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Json(req): Json<AnnounceRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let announced_did: Did = req
.did
.parse()
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
if let Some(Extension(auth)) = auth {
if auth.0 != announced_did.to_string() {
return Err(AppError::BadRequest(
"Signature keyid must match announced DID".into(),
));
}
} else {
tracing::warn!(
did = %announced_did,
"accepted unsigned peer announce; set GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true after all peers upgrade"
);
}
// Validate the URL is HTTP/HTTPS
if !req.http_url.starts_with("http://") && !req.http_url.starts_with("https://") {
return Err(AppError::BadRequest(
"http_url must start with http:// or https://".into(),
));
}
// Reject self-announcements: a peer row whose http_url is our own public
// URL makes the HTTP-notify path fan out to ourselves. Seen in prod when
// misconfigured dev nodes announce with their upstream's URL.
// prune_self_peers clears stale rows at boot; this stops new ones.
if let Some(self_url) = state.config.public_url.as_deref() {
if req.http_url.trim_end_matches('/') == self_url.trim_end_matches('/') {
return Err(AppError::BadRequest(
"http_url is this node's own public URL; refusing to register self as peer".into(),
));
}
}
if announced_did.to_string() == state.node_did.to_string() {
return Err(AppError::BadRequest(
"did is this node's own DID; refusing to register self as peer".into(),
));
return Err(AppError::BadRequest("http_url must start with http:// or https://".into()));
}
state.db.upsert_peer(&req.did, &req.http_url).await?;
@@ -112,16 +71,13 @@ pub async fn announce(
tracing::info!(did = %req.did, url = %req.http_url, "peer announced");
// Return our own info so the peer can add us back
Ok((
StatusCode::OK,
Json(serde_json::json!({
"status": "accepted",
"node_did": state.node_did.to_string(),
"node_url": state.config.public_url.as_deref().unwrap_or(""),
"peer_count": state.db.list_peers().await.map(|p| p.len()).unwrap_or(0),
"message": "added to peer list",
})),
))
Ok((StatusCode::OK, Json(serde_json::json!({
"status": "accepted",
"node_did": state.node_did.to_string(),
"node_url": state.config.public_url.as_deref().unwrap_or(""),
"peer_count": state.db.list_peers().await.map(|p| p.len()).unwrap_or(0),
"message": "added to peer list",
}))))
}
/// POST /api/v1/sync/trigger
@@ -130,7 +86,9 @@ pub async fn announce(
/// peer's repo list over HTTP and enqueues any repos we don't have or are
/// behind on into the sync_queue. This is the HTTP fallback when Gossipsub
/// p2p is not yet connected.
pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
pub async fn trigger_sync(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>> {
let peers = state.db.list_peers().await?;
let client = &state.http_client;
let mut enqueued = 0u32;
@@ -141,56 +99,39 @@ pub async fn trigger_sync(State(state): State<AppState>) -> Result<Json<serde_js
continue;
}
let url = format!("{}/api/v1/repos", peer.http_url.trim_end_matches('/'));
// 30s with the body read inside the timeout: 5s only covered the
// response headers, so canonical nodes serving large unpaginated repo
// lists (and transpacific round trips) aborted mid-body.
let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("peer returned status {}", resp.status()));
}
let repos: Vec<serde_json::Value> = resp.json().await?;
Ok::<_, anyhow::Error>(repos)
})
.await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
client.get(&url).send(),
).await;
let repos = match result {
Ok(Ok(repos)) => {
let repos: Vec<serde_json::Value> = match result {
Ok(Ok(resp)) if resp.status().is_success() => {
peers_reached += 1;
repos
resp.json().await.unwrap_or_default()
}
Ok(Err(e)) => {
tracing::warn!(peer = %peer.did, err = %e, "trigger_sync: peer fetch failed");
continue;
}
Err(_) => {
tracing::warn!(peer = %peer.did, "trigger_sync: peer timed out");
_ => {
tracing::warn!(peer = %peer.did, "trigger_sync: could not reach peer");
continue;
}
};
for repo in repos {
let repo_slug = match (
repo.get("owner_did").and_then(|v| v.as_str()),
repo.get("name").and_then(|v| v.as_str()),
) {
let repo_slug = match (repo.get("owner_did").and_then(|v| v.as_str()),
repo.get("name").and_then(|v| v.as_str())) {
(Some(owner), Some(name)) => {
// Use short owner (last colon segment) matching DB convention
let short = owner.split(':').next_back().unwrap_or(owner);
let short = owner.split(':').last().unwrap_or(owner);
format!("{short}/{name}")
}
_ => continue,
};
let _ = state
.db
.enqueue_sync(
&repo_slug,
&peer.did,
"refs/heads/main",
"0000000000000000000000000000000000000000",
None,
)
.await;
let _ = state.db.enqueue_sync(
&repo_slug,
&peer.did,
"refs/heads/main",
"0000000000000000000000000000000000000000",
None,
).await;
enqueued += 1;
}
}
@@ -214,38 +155,12 @@ pub struct NotifyRequest {
pub ref_name: String,
pub new_sha: String,
pub node_did: String,
// Optional fields — older senders only included the four above. New
// senders include these so received_ref_updates has full provenance
// even when the libp2p mesh isn't delivering and the HTTP fallback
// is the only path that fired.
#[serde(default)]
pub pusher_did: Option<String>,
#[serde(default)]
pub old_sha: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub cert_id: Option<String>,
}
pub async fn notify_sync(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Json(req): Json<NotifyRequest>,
) -> Result<Json<serde_json::Value>> {
if let Some(Extension(auth)) = auth {
if auth.0 != req.node_did {
return Err(AppError::BadRequest(
"Signature keyid must match notification node_did".into(),
));
}
} else {
tracing::warn!(
did = %req.node_did,
"accepted unsigned sync notify; set GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true after all peers upgrade"
);
}
// Only accept notifications from known peers
let peers = state.db.list_peers().await?;
let known = peers.iter().any(|p| p.did == req.node_did);
@@ -256,31 +171,13 @@ pub async fn notify_sync(
)));
}
state
.db
.enqueue_sync(&req.repo, &req.node_did, &req.ref_name, &req.new_sha, None)
.await?;
// Mirror the gossipsub-receive handler: insert the same record we'd
// get from the libp2p path, so /api/v1/events/ref-updates reflects
// pushes that arrive over either transport.
let now = chrono::Utc::now().to_rfc3339();
let update = crate::db::ReceivedRefUpdate {
id: uuid::Uuid::new_v4().to_string(),
node_did: req.node_did.clone(),
pusher_did: req.pusher_did.clone().unwrap_or_default(),
repo: req.repo.clone(),
ref_name: req.ref_name.clone(),
old_sha: req.old_sha.clone().unwrap_or_default(),
new_sha: req.new_sha.clone(),
timestamp: req.timestamp.clone().unwrap_or_else(|| now.clone()),
cert_id: req.cert_id.clone(),
received_at: now,
from_peer: format!("http:{}", req.node_did),
};
if let Err(e) = state.db.insert_ref_update(&update).await {
tracing::warn!(err = %e, repo = %req.repo, "failed to insert ref-update from sync notify");
}
state.db.enqueue_sync(
&req.repo,
&req.node_did,
&req.ref_name,
&req.new_sha,
None,
).await?;
tracing::info!(
repo = %req.repo,
@@ -304,17 +201,12 @@ pub async fn ping_peer(
Path(did): Path<String>,
) -> Result<Json<serde_json::Value>> {
let peers = state.db.list_peers().await?;
let peer = peers
.into_iter()
.find(|p| p.did == did)
let peer = peers.into_iter().find(|p| p.did == did)
.ok_or_else(|| AppError::RepoNotFound(format!("peer {did} not found")))?;
// Async ping
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = reqwest::get(&url)
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
let ok = reqwest::get(&url).await.map(|r| r.status().is_success()).unwrap_or(false);
let _ = state.db.mark_peer_ping(&did, ok).await;
-171
View File
@@ -1,171 +0,0 @@
//! Agent profile API handlers.
//!
//! - `PUT /api/v1/profile` — upsert the caller's profile (requires HTTP Signature)
//! - `GET /api/v1/agents/{did}/profile` — read any agent's profile (public)
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::auth::AuthenticatedDid;
use crate::pinata;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct SetProfileRequest {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub website: Option<String>,
pub socials: Option<SocialsInput>,
pub pin_to_ipfs: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SocialsInput {
pub twitter: Option<String>,
pub github: Option<String>,
pub farcaster: Option<String>,
pub telegram: Option<String>,
}
pub async fn set_profile(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<AuthenticatedDid>,
Json(req): Json<SetProfileRequest>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
let did = auth.0;
if let Some(ref bio) = req.bio {
if bio.len() > 280 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "bio must be 280 characters or fewer" })),
));
}
}
if let Some(ref name) = req.display_name {
if name.len() > 50 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "display_name must be 50 characters or fewer" })),
));
}
}
let socials_json = req
.socials
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_else(|_| "{}".to_string()));
let result = state
.db
.upsert_profile(
&did,
req.display_name.as_deref(),
req.bio.as_deref(),
req.avatar_url.as_deref(),
req.website.as_deref(),
socials_json.as_deref(),
)
.await;
match result {
Ok(profile) => {
let mut resp = json!({
"did": profile.did,
"display_name": profile.display_name,
"bio": profile.bio,
"avatar_url": profile.avatar_url,
"website": profile.website,
"updated_at": profile.updated_at,
});
if let Some(ref socials_str) = profile.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}
if req.pin_to_ipfs.unwrap_or(false) && !state.config.pinata_jwt.is_empty() {
let profile_json = serde_json::to_vec(&resp).unwrap_or_default();
match pinata::pin_object(
&state.http_client,
&state.config.pinata_upload_url,
&state.config.pinata_jwt,
&format!("profile-{}", &did),
&profile_json,
)
.await
{
Ok(cid) if !cid.is_empty() => {
if let Err(e) = state.db.set_profile_cid(&did, &cid).await {
tracing::warn!(did = %did, err = %e, "failed to store profile CID");
}
resp["profile_cid"] = json!(cid);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(did = %did, err = %e, "IPFS pin failed — profile saved without CID");
}
}
} else if let Some(cid) = profile.profile_cid {
resp["profile_cid"] = json!(cid);
}
Ok((StatusCode::OK, Json(resp)))
}
Err(e) => {
tracing::error!(did = %did, error = %e, "failed to upsert profile");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to save profile" })),
))
}
}
}
pub async fn get_profile(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let profile = state.db.get_profile(&did).await.map_err(|e| {
tracing::error!(did = %did, error = %e, "failed to fetch profile");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to fetch profile" })),
)
})?;
match profile {
Some(p) => {
let mut resp = json!({
"did": p.did,
"display_name": p.display_name,
"bio": p.bio,
"avatar_url": p.avatar_url,
"website": p.website,
"profile_cid": p.profile_cid,
"created_at": p.created_at,
"updated_at": p.updated_at,
});
if let Some(ref socials_str) = p.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}
Ok(Json(resp))
}
None => Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "profile not found" })),
)),
}
}
+10 -30
View File
@@ -17,19 +17,12 @@ pub async fn protect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
// Only the repo owner can protect branches
let caller = &auth.0;
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can protect branches".into(),
@@ -40,14 +33,11 @@ pub async fn protect_branch(
tracing::info!(repo = %repo, branch = %branch, caller = %caller, "branch protected");
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
})),
))
Ok((StatusCode::CREATED, Json(serde_json::json!({
"status": "protected",
"repo": format!("{owner}/{repo}"),
"branch": branch,
}))))
}
/// DELETE /api/v1/repos/:owner/:repo/branches/:branch/protect
@@ -56,18 +46,11 @@ pub async fn unprotect_branch(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo, branch)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
let owner_short = record.owner_did.split(':').next_back().unwrap_or(&record.owner_did);
if caller != &record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can unprotect branches".into(),
@@ -90,10 +73,7 @@ pub async fn list_protected_branches(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let branches = state.db.list_protected_branches(&record.id).await?;
+29 -107
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::auth::AuthenticatedDid;
use crate::db::{PrComment, PrReview, PullRequest};
use crate::db::{PullRequest, PrComment, PrReview};
use crate::error::{AppError, Result};
use crate::git::store;
use crate::state::AppState;
@@ -40,16 +40,11 @@ pub async fn create_pr(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreatePrRequest>,
) -> Result<(StatusCode, Json<PullRequest>)> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let author_did = auth.0;
let target_branch = req
.target_branch
.unwrap_or_else(|| record.default_branch.clone());
let target_branch = req.target_branch.unwrap_or_else(|| record.default_branch.clone());
let number = state.db.next_pr_number(&record.id).await?;
let now = Utc::now().to_rfc3339();
@@ -73,11 +68,7 @@ pub async fn create_pr(
// Bump trust score for the PR author — increment current score by 0.05
// (avoids the push_count=0 stuck-at-0.05 bug for agents who only open PRs)
let current = state
.db
.get_trust_score(&pr.author_did)
.await
.unwrap_or(0.05);
let current = state.db.get_trust_score(&pr.author_did).await.unwrap_or(0.05);
let new_score = (current + 0.05).min(1.0);
let _ = state.db.update_trust_score(&pr.author_did, new_score).await;
@@ -102,16 +93,11 @@ pub async fn list_prs(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let prs = state.db.list_prs(&record.id).await?;
Ok(Json(
serde_json::json!({ "pulls": prs, "count": prs.len() }),
))
Ok(Json(serde_json::json!({ "pulls": prs, "count": prs.len() })))
}
/// GET /api/v1/repos/:owner/:repo/pulls/:number
@@ -119,16 +105,10 @@ pub async fn get_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<PullRequest>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
Ok(Json(pr))
@@ -139,22 +119,13 @@ pub async fn get_pr_diff(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let disk_path = state
.repo_store
.acquire(&record.owner_did, &record.name)
.await
let disk_path = state.repo_store.acquire(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let diff = store::branch_diff(&disk_path, &pr.target_branch, &pr.source_branch)
.map_err(|e| AppError::Git(e.to_string()))?;
@@ -172,39 +143,24 @@ pub async fn merge_pr(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
if pr.status != "open" {
return Err(AppError::BadRequest(format!("PR is already {}", pr.status)));
}
let guard = state
.repo_store
.acquire_write(&record.owner_did, &record.name)
.await
let guard = state.repo_store.acquire_write(&record.owner_did, &record.name).await
.map_err(|e| AppError::Git(e.to_string()))?;
let disk_path = guard.path().to_path_buf();
let merger_did = auth.0;
let merge_result = store::merge_branch(
&disk_path,
&pr.target_branch,
&pr.source_branch,
&merger_did,
&pr.title,
);
let merge_result = store::merge_branch(&disk_path, &pr.target_branch, &pr.source_branch, &merger_did, &pr.title);
// Always release the advisory lock — even on error; upload to Tigris only on success.
guard.release(merge_result.is_ok()).await;
// Always release the advisory lock — even on error
guard.release().await;
let merge_sha = merge_result.map_err(|e| AppError::Git(e.to_string()))?;
@@ -238,16 +194,10 @@ pub async fn close_pr(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
state.db.close_pr(&pr.id).await?;
@@ -274,23 +224,15 @@ pub async fn create_review(
Path((owner, name, number)): Path<(String, String, i64)>,
Json(req): Json<CreateReviewRequest>,
) -> Result<(StatusCode, Json<PrReview>)> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let valid_statuses = ["approved", "changes_requested", "comment"];
if !valid_statuses.contains(&req.status.as_str()) {
return Err(AppError::BadRequest(
"status must be approved, changes_requested, or comment".into(),
));
return Err(AppError::BadRequest("status must be approved, changes_requested, or comment".into()));
}
let review = PrReview {
@@ -325,16 +267,10 @@ pub async fn list_reviews(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let reviews = state.db.list_pr_reviews(&pr.id).await?;
@@ -349,21 +285,13 @@ pub async fn create_comment(
Json(req): Json<CreateCommentRequest>,
) -> Result<(StatusCode, Json<PrComment>)> {
if req.body.trim().is_empty() {
return Err(AppError::BadRequest(
"comment body must not be empty".into(),
));
return Err(AppError::BadRequest("comment body must not be empty".into()));
}
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comment = PrComment {
@@ -384,16 +312,10 @@ pub async fn list_comments(
State(state): State<AppState>,
Path((owner, name, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let pr = state
.db
.get_pr(&record.id, number)
.await?
let pr = state.db.get_pr(&record.id, number).await?
.ok_or_else(|| AppError::NotFound(format!("PR #{number} not found")))?;
let comments = state.db.list_pr_comments(&pr.id).await?;
+16 -26
View File
@@ -1,8 +1,8 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use gitlawb_core::did::Did;
use gitlawb_core::ucan::Ucan;
@@ -38,45 +38,35 @@ pub async fn register(
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>)> {
// Parse and validate the DID
let agent_did: Did = req
.did
.parse()
let agent_did: Did = req.did.parse()
.map_err(|e: gitlawb_core::Error| AppError::BadRequest(e.to_string()))?;
// Store the agent in the local index
state
.db
.register_agent(agent_did.as_str(), &req.capabilities)
.await?;
state.db.register_agent(agent_did.as_str(), &req.capabilities).await?;
// Grant a small baseline trust score on first registration (verified via HTTP Signature).
// Score grows further with pushes, PRs, and issue activity.
let initial_trust = 0.05;
let _ = state
.db
.update_trust_score(agent_did.as_str(), initial_trust)
.await;
let _ = state.db.update_trust_score(agent_did.as_str(), initial_trust).await;
// Issue a bootstrap UCAN from the node's identity
let ucan = Ucan::bootstrap(&state.node_keypair, agent_did.clone())
.map_err(|e| AppError::Internal(e.into()))?;
let exp = Utc::now() + chrono::Duration::days(30);
let ucan_encoded = ucan.encode().map_err(|e| AppError::Internal(e.into()))?;
let ucan_encoded = ucan.encode()
.map_err(|e| AppError::Internal(e.into()))?;
tracing::info!(did = %agent_did, "registered new agent");
Ok((
StatusCode::CREATED,
Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
}),
))
Ok((StatusCode::CREATED, Json(RegisterResponse {
status: "accepted".to_string(),
did: agent_did.to_string(),
ucan: ucan_encoded,
node: format!("{}:{}", state.config.host, state.config.port),
expires: exp.to_rfc3339(),
trust_score: initial_trust,
capabilities: req.capabilities,
message: "welcome to the network, agent.".to_string(),
})))
}
-202
View File
@@ -1,202 +0,0 @@
//! Repo replica registration API.
//!
//! Lets a node tell the origin "I'm hosting a replica of your repo." The
//! origin records the (replica DID, replica URL) pair and exposes the list
//! publicly so anyone can see how many nodes are mirroring a given repo.
//!
//! Endpoints:
//! - `PUT /api/v1/repos/:owner/:repo/replicas` (auth) register
//! - `DELETE /api/v1/repos/:owner/:repo/replicas` (auth) unregister
//! - `GET /api/v1/repos/:owner/:repo/replicas` (public) list
//!
//! Auth model: the caller's DID (verified via HTTP Signatures) is the
//! replica's identity. There's no separate "claim this URL" check — replicas
//! self-report their public URL. Operators viewing the list should treat
//! replica URLs as advisory until they actually reach out and verify.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct RegisterReplicaRequest {
/// Publicly reachable URL of the replica node (e.g. `https://my-node.example.com`).
pub url: String,
}
/// PUT /api/v1/repos/:owner/:repo/replicas
/// Idempotent — first registration returns 201, subsequent ones update the URL and return 200.
pub async fn register_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RegisterReplicaRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
validate_replica_url(&req.url)?;
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
// Don't let an owner register themselves as a replica of their own repo.
if replica_did == &record.owner_did {
return Err(AppError::BadRequest(
"the repo owner is not a replica of their own repo".into(),
));
}
let inserted = state
.db
.register_replica(&record.id, replica_did, &req.url)
.await?;
let count = state.db.count_replicas(&record.id).await?;
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
tracing::info!(
repo = %repo,
replica = %replica_did,
url = %req.url,
"replica registered"
);
Ok((
status,
Json(serde_json::json!({
"status": "registered",
"repo": format!("{owner}/{repo}"),
"replica_did": replica_did,
"replica_url": req.url,
"replica_count": count,
})),
))
}
/// DELETE /api/v1/repos/:owner/:repo/replicas
/// Idempotent — no error if the caller wasn't registered.
pub async fn unregister_replica(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replica_did = &auth.0;
state.db.unregister_replica(&record.id, replica_did).await?;
let count = state.db.count_replicas(&record.id).await?;
tracing::info!(repo = %repo, replica = %replica_did, "replica unregistered");
Ok(Json(serde_json::json!({
"status": "unregistered",
"repo": format!("{owner}/{repo}"),
"replica_count": count,
})))
}
/// GET /api/v1/repos/:owner/:repo/replicas
/// Public — returns the list of replicas (DID + URL + registration timestamp).
pub async fn list_replicas(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let replicas = state.db.list_replicas(&record.id).await?;
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"replica_count": replicas.len(),
"replicas": replicas,
})))
}
/// Basic URL hygiene — must be http(s), parseable, length-bounded.
fn validate_replica_url(url: &str) -> Result<()> {
if url.is_empty() {
return Err(AppError::BadRequest("replica url is empty".into()));
}
if url.len() > 512 {
return Err(AppError::BadRequest("replica url exceeds 512 chars".into()));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(AppError::BadRequest(
"replica url must start with http:// or https://".into(),
));
}
// No spaces / control chars / newlines.
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(AppError::BadRequest(
"replica url contains whitespace or control characters".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_accepts_normal_https() {
validate_replica_url("https://node.example.com").unwrap();
validate_replica_url("https://my-node.example.com:7545").unwrap();
validate_replica_url("http://localhost:7545").unwrap();
}
#[test]
fn url_rejects_empty() {
assert!(validate_replica_url("").is_err());
}
#[test]
fn url_rejects_non_http_scheme() {
for bad in [
"ftp://host",
"file:///etc/passwd",
"javascript:alert(1)",
"/path",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_whitespace_and_control() {
for bad in [
"https://host .com",
"https://host\n.com",
"https://host\t.com",
"https://host\0evil.com",
] {
assert!(validate_replica_url(bad).is_err(), "{bad:?} must reject");
}
}
#[test]
fn url_rejects_overlong() {
let long = format!("https://{}.com", "a".repeat(600));
assert!(validate_replica_url(&long).is_err());
}
}
File diff suppressed because it is too large. Load diff
+9 -25
View File
@@ -19,32 +19,22 @@ pub async fn star_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
let inserted = state.db.star_repo(&record.id, caller).await?;
let count = state.db.count_stars(&record.id).await?;
let status = if inserted {
StatusCode::CREATED
} else {
StatusCode::OK
};
let status = if inserted { StatusCode::CREATED } else { StatusCode::OK };
tracing::info!(repo = %repo, caller = %caller, "repo starred");
Ok((
status,
Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
})),
))
Ok((status, Json(serde_json::json!({
"status": "starred",
"repo": format!("{owner}/{repo}"),
"star_count": count,
}))))
}
/// DELETE /api/v1/repos/:owner/:repo/star
@@ -54,10 +44,7 @@ pub async fn unstar_repo(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let caller = &auth.0;
@@ -79,10 +66,7 @@ pub async fn get_star_status(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
let record = state.db.get_repo(&owner, &repo).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let count = state.db.count_stars(&record.id).await?;
-249
View File
@@ -1,249 +0,0 @@
//! Path-scoped visibility management API. Owner-only, mirrors `api/protect.rs`.
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::auth::AuthenticatedDid;
use crate::db::VisibilityMode;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct SetVisibilityRequest {
pub path_glob: String,
/// "a" or "b"; defaults to "b" if absent or unrecognized.
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub reader_dids: Vec<String>,
}
#[derive(Deserialize)]
pub struct RemoveVisibilityRequest {
pub path_glob: String,
}
fn require_owner(record: &crate::db::RepoRecord, caller: &str) -> Result<()> {
let owner_short = record
.owner_did
.split(':')
.next_back()
.unwrap_or(&record.owner_did);
if caller != record.owner_did && caller != owner_short {
return Err(AppError::BadRequest(
"only the repo owner can manage visibility".into(),
));
}
Ok(())
}
/// Reject malformed globs before they reach the store, where they would
/// silently misconfigure access (an empty glob behaves like "/", and a glob
/// without a leading "/" never matches a real repo path). The accepted forms
/// match what `visibility_check` understands: "/", "/prefix", or "/prefix/**".
fn validate_path_glob(path_glob: &str) -> Result<()> {
if !path_glob.starts_with('/') {
return Err(AppError::BadRequest("path_glob must start with '/'".into()));
}
if path_glob == "/**" {
return Err(AppError::BadRequest(
"use '/' for whole-repo scope, not '/**'".into(),
));
}
if path_glob != "/" && path_glob.ends_with('/') {
return Err(AppError::BadRequest(
"path_glob must not end with '/'".into(),
));
}
if path_glob.contains('*') && !path_glob.ends_with("/**") {
return Err(AppError::BadRequest(
"the only supported wildcard is a trailing '/**'".into(),
));
}
Ok(())
}
/// PUT /api/v1/repos/{owner}/{repo}/visibility
pub async fn set_visibility(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<SetVisibilityRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
validate_path_glob(&req.path_glob)?;
let mode = match req.mode.as_deref() {
Some("a") => VisibilityMode::A,
_ => VisibilityMode::B,
};
// Mode A hides existence and is only coherent for the whole repo; a subtree
// cannot hide its existence without rewriting git history (see spec).
if mode == VisibilityMode::A && req.path_glob != "/" {
return Err(AppError::BadRequest(
"mode 'a' (hide) is only allowed for the whole repo (path_glob '/'); use mode 'b' for subtrees".into(),
));
}
// An empty reader_dids list is valid and intentional: the owner is always
// allowed by visibility_check, so a "/" rule with no readers is exactly the
// whole-repo "private to owner only" case.
state
.db
.set_visibility_rule(&record.id, &req.path_glob, mode, &req.reader_dids, &auth.0)
.await?;
let subtree_warning = req.path_glob != "/";
tracing::info!(
repo = %repo, caller = %auth.0, path_glob = %req.path_glob, mode = %mode.as_str(),
subtree_pending = subtree_warning,
"visibility rule set"
);
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"status": "set",
"repo": format!("{owner}/{repo}"),
"path_glob": req.path_glob,
"mode": mode.as_str(),
"reader_dids": req.reader_dids,
"subtree_clone_enforcement_pending": subtree_warning,
})),
))
}
/// DELETE /api/v1/repos/{owner}/{repo}/visibility
pub async fn remove_visibility(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
Json(req): Json<RemoveVisibilityRequest>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
state
.db
.remove_visibility_rule(&record.id, &req.path_glob)
.await?;
tracing::info!(
repo = %repo, caller = %auth.0, path_glob = %req.path_glob,
"visibility rule removed"
);
Ok(Json(serde_json::json!({
"status": "removed",
"repo": format!("{owner}/{repo}"),
"path_glob": req.path_glob,
})))
}
/// GET /api/v1/repos/{owner}/{repo}/visibility
pub async fn list_visibility(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
require_owner(&record, &auth.0)?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let rules_json: Vec<_> = rules
.into_iter()
.map(|r| {
serde_json::json!({
"path_glob": r.path_glob,
"mode": r.mode.as_str(),
"reader_dids": r.reader_dids,
"created_by": r.created_by,
})
})
.collect();
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"count": rules_json.len(),
"rules": rules_json,
})))
}
/// GET /api/v1/repos/{owner}/{repo}/withheld-paths
///
/// Returns the path globs the (optionally authenticated) caller is denied
/// (`withheld`) plus any more-specific globs that are allowed underneath a
/// denied one (`reinclude`), so a clean-clone client can sparse-exclude the
/// denied subtrees while re-including the allowed nested paths. Unlike
/// `list_visibility` this is not owner-gated and never exposes reader_dids.
pub async fn withheld_paths(
State(state): State<AppState>,
auth: Option<Extension<AuthenticatedDid>>,
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &repo)
.await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{repo}")))?;
let rules = state.db.list_visibility_rules(&record.id).await?;
let caller = auth.as_ref().map(|e| e.0 .0.as_str());
// Whole-repo read gate: a caller who cannot read "/" gets repo-not-found,
// matching the git read endpoints, so this never discloses a private repo's
// existence or its path layout to an unauthorized caller.
if crate::visibility::visibility_check(&rules, record.is_public, &record.owner_did, caller, "/")
== crate::visibility::Decision::Deny
{
return Err(AppError::RepoNotFound(format!("{owner}/{repo}")));
}
let withheld =
crate::visibility::withheld_globs(&rules, record.is_public, &record.owner_did, caller);
let reinclude =
crate::visibility::reincluded_globs(&rules, record.is_public, &record.owner_did, caller);
Ok(Json(serde_json::json!({
"repo": format!("{owner}/{repo}"),
"withheld": withheld,
"reinclude": reinclude,
})))
}
#[cfg(test)]
mod tests {
use super::validate_path_glob;
#[test]
fn accepts_supported_globs() {
for g in ["/", "/secret", "/secret/**", "/a/b/c", "/a/b/**"] {
assert!(validate_path_glob(g).is_ok(), "{g} should be valid");
}
}
#[test]
fn rejects_malformed_globs() {
// empty, no leading slash, whole-repo via "/**", trailing slash, and
// non-trailing wildcards are all rejected.
for g in ["", "secret/**", "/**", "/secret/", "/a*b", "/*/x"] {
assert!(validate_path_glob(g).is_err(), "{g} should be rejected");
}
}
}
+10 -31
View File
@@ -33,17 +33,12 @@ pub async fn create_webhook(
Path((owner, name)): Path<(String, String)>,
Json(req): Json<CreateWebhookRequest>,
) -> Result<(StatusCode, Json<Webhook>)> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// Validate URL is http/https
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(AppError::BadRequest(
"webhook URL must be http:// or https://".into(),
));
return Err(AppError::BadRequest("webhook URL must be http:// or https://".into()));
}
let events = req.events.unwrap_or_else(|| vec!["*".into()]);
@@ -69,27 +64,17 @@ pub async fn list_webhooks(
State(state): State<AppState>,
Path((owner, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
let hooks = state.db.list_webhooks(&record.id).await?;
// Redact secrets in list response
let redacted: Vec<_> = hooks
.into_iter()
.map(|mut h| {
if h.secret.is_some() {
h.secret = Some("***".into());
}
h
})
.collect();
let redacted: Vec<_> = hooks.into_iter().map(|mut h| {
if h.secret.is_some() { h.secret = Some("***".into()); }
h
}).collect();
Ok(Json(
serde_json::json!({ "webhooks": redacted, "count": redacted.len() }),
))
Ok(Json(serde_json::json!({ "webhooks": redacted, "count": redacted.len() })))
}
/// DELETE /api/v1/repos/:owner/:repo/hooks/:id
@@ -97,17 +82,11 @@ pub async fn delete_webhook(
State(state): State<AppState>,
Path((owner, name, id)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let record = state
.db
.get_repo(&owner, &name)
.await?
let record = state.db.get_repo(&owner, &name).await?
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
// Verify the webhook belongs to this repo
let hook = state
.db
.get_webhook(&id)
.await?
let hook = state.db.get_webhook(&id).await?
.ok_or_else(|| AppError::NotFound(format!("webhook {id} not found")))?;
if hook.repo_id != record.id {
+6 -11
View File
@@ -20,6 +20,7 @@
use anyhow::Result;
use serde_json::json;
/// Data describing a ref-update event to be anchored.
#[derive(Debug, Clone)]
pub struct RefAnchor {
@@ -111,9 +112,9 @@ pub fn arweave_url(tx_id: &str) -> String {
/// Build the Irys tag header value for Arweave indexing.
/// Format: comma-separated "name:value" pairs.
fn build_tags_header(anchor: &RefAnchor) -> String {
[
"App-Name:gitlawb".to_string(),
"Schema:gitlawb/ref-update/v1".to_string(),
vec![
format!("App-Name:gitlawb"),
format!("Schema:gitlawb/ref-update/v1"),
format!("Repo:{}", sanitize_tag(&anchor.repo)),
format!("Ref:{}", sanitize_tag(&anchor.ref_name)),
format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]),
@@ -177,20 +178,14 @@ mod tests {
let result = anchor_ref_update(&client, &server.url(), &anchor).await;
assert!(result.is_ok(), "anchor should succeed: {result:?}");
assert_eq!(
result.unwrap(),
"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
assert_eq!(result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
_mock.assert_async().await;
}
#[test]
fn test_arweave_url() {
let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
assert_eq!(
url,
"https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"
);
assert_eq!(url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk");
}
#[test]
+26 -381
View File
@@ -1,5 +1,5 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
@@ -8,11 +8,6 @@ use http_body_util::BodyExt;
use serde_json::json;
use std::collections::HashMap;
use gitlawb_core::did::Did;
use gitlawb_core::ucan::Ucan;
use crate::state::AppState;
/// The authenticated agent's DID, injected into request extensions by `require_signature`.
#[derive(Clone, Debug)]
pub struct AuthenticatedDid(pub String);
@@ -39,17 +34,16 @@ use gitlawb_core::identity::verify;
pub async fn require_signature(request: Request, next: Next) -> Response {
// Buffer the body so we can verify content-digest and pass it downstream
let (parts, body) = request.into_parts();
let body_bytes =
match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => return (
let body_bytes = match body.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "unreadable_body", "message": "could not read request body" }),
),
Json(json!({ "error": "unreadable_body", "message": "could not read request body" })),
)
.into_response(),
};
.into_response()
}
};
let sig_input = parts
.headers
@@ -67,9 +61,8 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
(Some(i), Some(s)) => (i, s),
_ => {
return human_detected(
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures",
)
.into_response();
"missing Signature-Input or Signature headers — use RFC 9421 HTTP Signatures"
).into_response();
}
};
@@ -163,21 +156,22 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
request_values.insert("content-digest".to_string(), content_digest);
// The @signature-params value is the part of Signature-Input after "sig1="
let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input);
let sig_params_value = sig_input
.strip_prefix("sig1=")
.unwrap_or(&sig_input);
let components_ref: Vec<&str> = sig.components.iter().map(String::as_str).collect();
let signing_string =
match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
let signing_string = match build_signing_string(&components_ref, sig_params_value, &request_values) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "signing_string_error", "message": e.to_string() })),
)
.into_response()
}
};
// Verify Ed25519 signature
let sig_array: [u8; 64] = match sig.signature_bytes.as_slice().try_into() {
@@ -206,11 +200,7 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
}
// Verify Content-Digest matches the actual request body
if let Some(claimed) = parts
.headers
.get("content-digest")
.and_then(|v| v.to_str().ok())
{
if let Some(claimed) = parts.headers.get("content-digest").and_then(|v| v.to_str().ok()) {
let actual = compute_content_digest(&body_bytes);
if claimed != actual {
return (
@@ -227,130 +217,7 @@ pub async fn require_signature(request: Request, next: Next) -> Response {
tracing::info!(did = %sig.key_id, "✓ authenticated request");
let mut request = Request::from_parts(parts, Body::from(body_bytes));
request
.extensions_mut()
.insert(AuthenticatedDid(sig.key_id.to_string()));
next.run(request).await
}
/// Optional variant for rolling upgrades: verify and inject `AuthenticatedDid` when
/// RFC 9421 signature headers are present, but allow legacy unsigned requests to
/// continue when no signature attempt was made.
pub async fn optional_signature(request: Request, next: Next) -> Response {
let has_signature_headers = request.headers().contains_key("signature-input")
|| request.headers().contains_key("signature");
if has_signature_headers {
return require_signature(request, next).await;
}
next.run(request).await
}
/// Validate a raw UCAN token string supplied in `X-Ucan`.
///
/// Checks performed:
/// 1. The token decodes to a valid [`Ucan`] structure.
/// 2. The UCAN issuer (`iss`) matches `signer_did` — the DID that signed the
/// HTTP request — preventing replay of another agent's UCAN.
/// 3. The UCAN audience (`aud`) matches `expected_aud` — the node's own DID.
/// 4. The full proof chain is cryptographically valid (signatures, expiry,
/// not-before, chain linkage, and capability attenuation).
fn validate_ucan_chain(
token: &str,
expected_aud: &Did,
signer_did: &Did,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let ucan = Ucan::decode(token).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
if &ucan.payload.iss != signer_did {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "invalid_ucan",
"message": format!(
"UCAN issuer {} does not match request signer {}",
ucan.payload.iss, signer_did
),
})),
));
}
ucan.verify_audience(expected_aud).map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
ucan.verify_chain().map_err(|e| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_ucan", "message": e.to_string() })),
)
})?;
Ok(())
}
/// Axum middleware that validates a UCAN chain when `X-Ucan` is present.
///
/// Must be layered so that it runs after [`require_signature`], which sets the
/// [`AuthenticatedDid`] extension consumed here.
///
/// When `X-Ucan` is absent the request passes through unchanged, preserving
/// backward compatibility for agents that pre-date UCAN delegation. When the
/// header is present the full chain is validated: the UCAN issuer must match
/// the HTTP Signature identity, the audience must be this node's DID, and
/// every proof in the chain must be cryptographically sound with no capability
/// escalation.
pub async fn require_ucan_chain(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let token = match request
.headers()
.get("x-ucan")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
{
Some(t) => t,
None => return next.run(request).await,
};
let signer_did: Did = match request.extensions().get::<AuthenticatedDid>() {
Some(a) => match a.0.parse() {
Ok(did) => did,
Err(e) => {
tracing::warn!(raw_did = %a.0, err = %e, "failed to parse DID from authenticated identity");
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_identity", "message": "invalid DID in token" })),
)
.into_response();
}
},
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "invalid_ucan",
"message": "UCAN validation requires a valid HTTP Signature",
})),
)
.into_response()
}
};
if let Err((status, body)) = validate_ucan_chain(&token, &state.node_did, &signer_did) {
return (status, body).into_response();
}
tracing::debug!(did = %signer_did, "UCAN chain validated");
request.extensions_mut().insert(AuthenticatedDid(sig.key_id.to_string()));
next.run(request).await
}
@@ -372,225 +239,3 @@ fn human_detected(message: &str) -> impl IntoResponse {
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{middleware, Router};
use gitlawb_core::identity::Keypair;
use gitlawb_core::ucan::{caps, Capability, Ucan};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tower::ServiceExt;
fn bootstrap_ucan(node: &Keypair, agent_did: Did) -> Ucan {
Ucan::bootstrap(node, agent_did).unwrap()
}
fn delegation_ucan(agent: &Keypair, node_did: Did, proof: &Ucan) -> Ucan {
Ucan::delegate(
agent,
node_did,
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
None,
proof,
)
.unwrap()
}
#[test]
fn validate_ucan_chain_valid() {
let node = Keypair::generate();
let agent = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
assert!(validate_ucan_chain(&token, &node_did, &agent_did).is_ok());
}
#[test]
fn validate_ucan_chain_wrong_issuer() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
// signer_did is `other` but UCAN iss is `agent` — must be rejected
let err = validate_ucan_chain(&token, &node_did, &other.did()).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("does not match request signer"));
}
#[test]
fn validate_ucan_chain_wrong_audience() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other_node = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let proof = bootstrap_ucan(&node, agent_did.clone());
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
// expected_aud is a different node — must be rejected
let err = validate_ucan_chain(&token, &other_node.did(), &agent_did).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("audience mismatch"));
}
#[test]
fn validate_ucan_chain_expired_proof() {
let node = Keypair::generate();
let agent = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
let exp = chrono::Utc::now() - chrono::Duration::hours(1);
let proof = Ucan::issue(
&node,
agent_did.clone(),
vec![Capability::new("gitlawb://alpha", caps::NETWORK_JOIN)],
Some(exp),
)
.unwrap();
let delegation = delegation_ucan(&agent, node_did.clone(), &proof);
let token = delegation.encode().unwrap();
let err = validate_ucan_chain(&token, &node_did, &agent_did).unwrap_err();
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
let body = err.1 .0.to_string();
assert!(body.contains("expired"));
}
fn make_test_state(node_did: gitlawb_core::did::Did) -> crate::state::AppState {
use crate::{config::Config, graphql, rate_limit::RateLimiter};
use clap::Parser;
let keypair = Keypair::generate();
let (ref_tx, _) = tokio::sync::broadcast::channel(1);
let (task_tx, _) = tokio::sync::broadcast::channel(1);
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://localhost/gitlawb_test_placeholder")
.expect("lazy pool creation should not fail");
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
let schema = Arc::new(graphql::build_schema(
db.clone(),
ref_tx.clone(),
task_tx.clone(),
));
crate::state::AppState {
config: Arc::new(Config::parse_from(["gitlawb-node"])),
db,
node_did,
node_keypair: Arc::new(keypair),
p2p: None,
http_client: Arc::new(reqwest::Client::new()),
ref_update_tx: ref_tx,
task_event_tx: task_tx,
graphql_schema: schema,
machine_id: None,
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
shutdown_tx: tokio::sync::watch::channel(false).0,
}
}
#[tokio::test]
async fn require_ucan_chain_no_header_passes_through() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
let req = Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn require_ucan_chain_missing_did_returns_401() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// x-ucan present but no AuthenticatedDid extension → 401
let req = Request::builder()
.uri("/")
.header("x-ucan", "any-token")
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn require_ucan_chain_wrong_issuer_returns_401() {
let node = Keypair::generate();
let agent = Keypair::generate();
let other = Keypair::generate();
let node_did = node.did();
let agent_did = agent.did();
// Build a valid token where iss = agent, but supply `other` as the signer.
let proof = bootstrap_ucan(&node, agent_did.clone());
let token = delegation_ucan(&agent, node_did.clone(), &proof)
.encode()
.unwrap();
let state = make_test_state(node_did);
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// AuthenticatedDid is `other`, UCAN iss is `agent` → issuer mismatch → 401
let req = Request::builder()
.uri("/")
.header("x-ucan", token)
.extension(AuthenticatedDid(other.did().to_string()))
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn require_ucan_chain_malformed_token_returns_401() {
let state = make_test_state(Keypair::generate().did());
let app = Router::new()
.route("/", axum::routing::get(|| async { StatusCode::OK }))
.layer(middleware::from_fn_with_state(state, require_ucan_chain));
// Malformed x-ucan (invalid JSON)
let req = Request::builder()
.uri("/")
.header("x-ucan", "invalid-token-structure")
.extension(AuthenticatedDid(Keypair::generate().did().to_string()))
.body(axum::body::Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap();
let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body_json["error"], "invalid_ucan");
}
}
-316
View File
@@ -1,316 +0,0 @@
//! Embedded seed list of public Gitlawb network nodes.
//!
//! This module parses `bootstrap-peers.json` (embedded at compile time) and
//! merges its contents into the runtime config so a fresh `docker compose up`
//! joins the network without any manual peer configuration.
//!
//! Operators can opt out by setting `GITLAWB_BOOTSTRAP_DISABLE_SEEDS=true` in
//! their environment — useful for isolated dev networks or testing.
//!
//! Add a node to the canonical list via PR to `bootstrap-peers.json`.
use std::str::FromStr;
use libp2p_core::Multiaddr;
use serde::Deserialize;
use tracing::{info, warn};
use crate::config::Config;
const EMBEDDED_PEERS_JSON: &str = include_str!("../../../bootstrap-peers.json");
const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
struct BootstrapList {
version: u32,
#[serde(default)]
peers: Vec<BootstrapPeer>,
}
#[derive(Debug, Deserialize)]
struct BootstrapPeer {
name: String,
#[allow(dead_code)]
operator: Option<String>,
#[allow(dead_code)]
did: Option<String>,
http_url: Option<String>,
p2p_multiaddr: Option<String>,
#[allow(dead_code)]
added: Option<String>,
}
/// Counts of newly-added entries returned by `merge_into_vecs`.
#[derive(Debug, Default, PartialEq, Eq)]
struct MergeCounts {
http: usize,
p2p: usize,
}
/// Returns true when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value.
fn seeds_disabled() -> bool {
std::env::var("GITLAWB_BOOTSTRAP_DISABLE_SEEDS")
.ok()
.filter(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
.is_some()
}
/// Parse the seed list from a JSON string, rejecting unsupported versions.
fn parse_seed_list(json: &str) -> Result<BootstrapList, String> {
let list: BootstrapList = serde_json::from_str(json).map_err(|e| e.to_string())?;
if list.version != SUPPORTED_VERSION {
return Err(format!(
"unsupported bootstrap-peers.json version: {} (expected {})",
list.version, SUPPORTED_VERSION
));
}
Ok(list)
}
/// Pure merge: appends entries from `list` to the two vectors, deduping.
/// Returns counts of entries actually added (i.e. not already present).
fn merge_into_vecs(
list: BootstrapList,
http_peers: &mut Vec<String>,
p2p_bootstrap: &mut Vec<String>,
) -> MergeCounts {
let mut counts = MergeCounts::default();
for peer in list.peers {
if let Some(url) = peer
.http_url
.as_ref()
.filter(|u| !u.is_empty() && !http_peers.contains(u))
{
http_peers.push(url.clone());
counts.http += 1;
}
if let Some(addr_str) = peer.p2p_multiaddr.as_ref().filter(|s| !s.is_empty()) {
match Multiaddr::from_str(addr_str) {
Ok(_) => {
if !p2p_bootstrap.contains(addr_str) {
p2p_bootstrap.push(addr_str.clone());
counts.p2p += 1;
}
}
Err(e) => warn!(
name = %peer.name,
addr = %addr_str,
err = %e,
"invalid p2p_multiaddr in bootstrap-peers.json — skipping"
),
}
}
}
counts
}
/// Merge the embedded seed list into the runtime config.
///
/// - Appends any `http_url` to `config.bootstrap_peers` (used by gossip_task)
/// - Appends any valid `p2p_multiaddr` to `config.p2p_bootstrap` (used by libp2p)
/// - Dedupes against entries already present (env / CLI takes precedence)
/// - No-op when `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` is set to a truthy value
pub fn merge_seeds(config: &mut Config) {
if seeds_disabled() {
info!("bootstrap seed list disabled via GITLAWB_BOOTSTRAP_DISABLE_SEEDS");
return;
}
let list = match parse_seed_list(EMBEDDED_PEERS_JSON) {
Ok(l) => l,
Err(e) => {
warn!(err = %e, "failed to load embedded bootstrap-peers.json — skipping");
return;
}
};
let counts = merge_into_vecs(list, &mut config.bootstrap_peers, &mut config.p2p_bootstrap);
if counts.http > 0 || counts.p2p > 0 {
info!(
http_peers = counts.http,
p2p_peers = counts.p2p,
"merged bootstrap seed list into config"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_v1_list() {
let json = r#"{
"version": 1,
"updated": "2026-04-29",
"peers": [
{
"name": "alpha",
"operator": "Alice",
"did": "did:key:z6MkAlice",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546",
"added": "2026-04-29"
}
]
}"#;
let list = parse_seed_list(json).expect("should parse");
assert_eq!(list.version, 1);
assert_eq!(list.peers.len(), 1);
assert_eq!(list.peers[0].name, "alpha");
}
#[test]
fn parse_rejects_unknown_version() {
let json = r#"{ "version": 99, "peers": [] }"#;
let err = parse_seed_list(json).expect_err("should reject");
assert!(err.contains("unsupported"));
}
#[test]
fn parse_rejects_malformed_json() {
let err = parse_seed_list("{ not json").expect_err("should reject");
assert!(!err.is_empty());
}
#[test]
fn parse_accepts_empty_peers_array() {
let json = r#"{ "version": 1, "peers": [] }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn parse_treats_missing_peers_as_empty() {
let json = r#"{ "version": 1 }"#;
let list = parse_seed_list(json).expect("should parse");
assert!(list.peers.is_empty());
}
#[test]
fn merge_appends_new_http_and_p2p() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"http_url": "https://alpha.example.com",
"p2p_multiaddr": "/ip4/1.2.3.4/tcp/7546"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 1 });
assert_eq!(http, vec!["https://alpha.example.com"]);
assert_eq!(p2p, vec!["/ip4/1.2.3.4/tcp/7546"]);
}
#[test]
fn merge_dedupes_existing_entries() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "alpha", "http_url": "https://alpha.example.com" }
]
}"#,
)
.unwrap();
let mut http = vec!["https://alpha.example.com".to_string()];
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 0, "should not double-add");
assert_eq!(http.len(), 1);
}
#[test]
fn merge_skips_invalid_p2p_multiaddr() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "bad",
"http_url": "https://bad.example.com",
"p2p_multiaddr": "this is not a multiaddr"
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts.http, 1, "http still added");
assert_eq!(counts.p2p, 0, "invalid p2p skipped");
assert!(p2p.is_empty());
}
#[test]
fn merge_skips_empty_strings() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{ "name": "blank", "http_url": "", "p2p_multiaddr": "" }
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts::default());
}
#[test]
fn merge_handles_null_optional_fields() {
let list = parse_seed_list(
r#"{
"version": 1,
"peers": [
{
"name": "alpha",
"operator": null,
"did": null,
"http_url": "https://alpha.example.com",
"p2p_multiaddr": null,
"added": null
}
]
}"#,
)
.unwrap();
let mut http = Vec::new();
let mut p2p = Vec::new();
let counts = merge_into_vecs(list, &mut http, &mut p2p);
assert_eq!(counts, MergeCounts { http: 1, p2p: 0 });
}
#[test]
fn embedded_seed_list_parses_successfully() {
// Regression: the canonical bootstrap-peers.json shipped in the repo
// must always be valid, since it's compiled into the binary.
parse_seed_list(EMBEDDED_PEERS_JSON)
.expect("embedded bootstrap-peers.json must always parse");
}
}
+5 -5
View File
@@ -41,11 +41,11 @@ pub async fn issue_ref_certificate(
let signature = state.node_keypair.sign_b64(&payload_bytes);
let cert = RefCertificate {
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
id: Uuid::new_v4().to_string(),
repo_id: repo_id.to_string(),
ref_name: ref_name.to_string(),
old_sha: old_sha.to_string(),
new_sha: new_sha.to_string(),
pusher_did: pusher_did.to_string(),
node_did,
signature,
+7 -41
View File
@@ -1,5 +1,5 @@
use clap::Parser;
use std::path::PathBuf;
use clap::Parser;
#[derive(Parser, Debug, Clone)]
#[command(name = "gitlawb-node", about = "gitlawb node daemon", version)]
@@ -9,11 +9,7 @@ pub struct Config {
pub repos_dir: PathBuf,
/// PostgreSQL connection URL (Supabase or any Postgres instance)
#[arg(
long,
env = "DATABASE_URL",
default_value = "postgresql://localhost/gitlawb"
)]
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://localhost/gitlawb")]
pub database_url: String,
/// Host to bind to
@@ -28,7 +24,7 @@ pub struct Config {
#[arg(long, env = "GITLAWB_KEY", default_value = "~/.gitlawb/identity.pem")]
pub key_path: String,
/// Reserved for private-read mode; per-repo read enforcement is not wired in alpha
/// Allow unauthenticated reads (default: true for public repos)
#[arg(long, env = "GITLAWB_PUBLIC_READ", default_value_t = true)]
pub public_read: bool,
@@ -40,15 +36,6 @@ pub struct Config {
#[arg(long, env = "GITLAWB_BOOTSTRAP_PEERS", value_delimiter = ',')]
pub bootstrap_peers: Vec<String>,
/// Require RFC 9421 signatures on peer announce/sync write routes.
/// Keep false during rolling upgrades so existing live nodes can still gossip.
#[arg(
long,
env = "GITLAWB_REQUIRE_SIGNED_PEER_WRITES",
default_value_t = false
)]
pub require_signed_peer_writes: bool,
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
#[arg(long, env = "GITLAWB_IPFS_API", default_value = "")]
pub ipfs_api: String,
@@ -58,19 +45,15 @@ pub struct Config {
pub pinata_jwt: String,
/// Pinata v3 upload URL
#[arg(
long,
env = "GITLAWB_PINATA_UPLOAD_URL",
default_value = "https://uploads.pinata.cloud/v3/files"
)]
#[arg(long, env = "GITLAWB_PINATA_UPLOAD_URL", default_value = "https://uploads.pinata.cloud/v3/files")]
pub pinata_upload_url: String,
/// libp2p QUIC/UDP port (0 = disabled)
/// libp2p TCP port (0 = disabled)
#[arg(long, env = "GITLAWB_P2P_PORT", default_value_t = 7546)]
pub p2p_port: u16,
/// libp2p bootstrap multiaddrs (comma-separated)
/// Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
/// Example: /ip4/1.2.3.4/tcp/7546/p2p/12D3KooW...
#[arg(long, env = "GITLAWB_P2P_BOOTSTRAP", value_delimiter = ',')]
pub p2p_bootstrap: Vec<String>,
@@ -92,11 +75,7 @@ pub struct Config {
pub contract_name_registry: String,
/// Base L2 RPC URL
#[arg(
long,
env = "GITLAWB_CHAIN_RPC_URL",
default_value = "https://sepolia.base.org"
)]
#[arg(long, env = "GITLAWB_CHAIN_RPC_URL", default_value = "https://sepolia.base.org")]
pub chain_rpc_url: String,
/// Base L2 node staking contract address (GitlawbNodeStaking). When set
@@ -131,19 +110,6 @@ pub struct Config {
/// Default: 2 GB. Set lower on resource-constrained nodes.
#[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)]
pub max_pack_bytes: usize,
/// Optional address to bind a Prometheus `/metrics` exposition endpoint on.
/// Example: `127.0.0.1:9091`. Leave empty (default) to disable.
/// Bind to localhost or a private interface — the metrics endpoint is
/// unauthenticated.
#[arg(long, env = "GITLAWB_METRICS_ADDR", default_value = "")]
pub metrics_addr: String,
/// Maximum time to wait for in-flight requests to drain on shutdown, in
/// seconds. After this elapses, the server returns 503 to anything still
/// in flight and exits. Default: 30s.
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
pub shutdown_grace_secs: u64,
}
impl Config {
File diff suppressed because it is too large. Load diff
+30 -6
View File
@@ -49,12 +49,36 @@ impl IntoResponse for AppError {
"repo_exists",
format!("repository '{r}' already exists"),
),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "not_an_agent", msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.clone()),
AppError::Git(msg) => (StatusCode::INTERNAL_SERVER_ERROR, "git_error", msg.clone()),
AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, "db_error", e.to_string()),
AppError::NotFound(msg) => (
StatusCode::NOT_FOUND,
"not_found",
msg.clone(),
),
AppError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
"not_an_agent",
msg.clone(),
),
AppError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
"forbidden",
msg.clone(),
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"bad_request",
msg.clone(),
),
AppError::Git(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
"git_error",
msg.clone(),
),
AppError::Db(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"db_error",
e.to_string(),
),
AppError::Internal(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
+24 -43
View File
@@ -25,9 +25,7 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
let mut child = hash_output;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin
.write_all(json.as_bytes())
.context("failed to write to git hash-object stdin")?;
stdin.write_all(json.as_bytes()).context("failed to write to git hash-object stdin")?;
}
let output = child.wait_with_output().context("git hash-object failed")?;
@@ -36,8 +34,8 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
anyhow::bail!("git hash-object failed: {stderr}");
}
let hash =
String::from_utf8(output.stdout).context("git hash-object output is not valid UTF-8")?;
let hash = String::from_utf8(output.stdout)
.context("git hash-object output is not valid UTF-8")?;
let hash = hash.trim();
// Update the ref
@@ -60,11 +58,7 @@ pub fn create_issue(repo_path: &Path, issue_id: &str, json: &str) -> Result<()>
pub fn list_issues(repo_path: &Path) -> Result<Vec<String>> {
// List all refs under refs/gitlawb/issues/
let list_output = Command::new("git")
.args([
"for-each-ref",
"--format=%(refname)",
"refs/gitlawb/issues/",
])
.args(["for-each-ref", "--format=%(refname)", "refs/gitlawb/issues/"])
.current_dir(repo_path)
.output()
.context("failed to run git for-each-ref")?;
@@ -127,7 +121,10 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
}
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
let matches: Vec<&str> = output
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
match matches.len() {
0 => Ok(None),
@@ -159,8 +156,8 @@ pub fn close_issue(repo_path: &Path, issue_id: &str) -> Result<Option<String>> {
let raw = get_issue(repo_path, &full_id)?
.expect("ref existed in resolve but not in get — should be impossible");
let mut issue: serde_json::Value =
serde_json::from_str(&raw).context("invalid issue JSON in git ref")?;
let mut issue: serde_json::Value = serde_json::from_str(&raw)
.context("invalid issue JSON in git ref")?;
issue["status"] = serde_json::Value::String("closed".to_string());
let updated = serde_json::to_string(&issue).context("failed to serialize updated issue")?;
@@ -196,6 +193,15 @@ mod tests {
use std::process::Command;
use tempfile::TempDir;
fn init_bare_repo(dir: &TempDir) {
Command::new("git")
.args(["init", "--bare"])
.current_dir(dir.path())
.output()
.unwrap();
// git hash-object -w needs a non-bare repo; use a working repo instead
}
fn init_repo(dir: &TempDir) {
Command::new("git")
.args(["init"])
@@ -219,12 +225,7 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), full_id).unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
@@ -234,12 +235,7 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "abc12345-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"abc12345-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some(full_id.to_string()));
}
@@ -256,18 +252,8 @@ mod tests {
fn test_resolve_ambiguous_prefix_errors() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(
dir.path(),
"abc12345-aaaa-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(
dir.path(),
"abc12345-bbbb-0000-0000-000000000000",
r#"{"status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), "abc12345-aaaa-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-bbbb-0000-0000-000000000000", r#"{"status":"open"}"#).unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ambiguous"));
@@ -278,12 +264,7 @@ mod tests {
let dir = TempDir::new().unwrap();
init_repo(&dir);
let full_id = "def99999-0000-0000-0000-000000000000";
create_issue(
dir.path(),
full_id,
r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#,
)
.unwrap();
create_issue(dir.path(), full_id, r#"{"id":"def99999-0000-0000-0000-000000000000","status":"open"}"#).unwrap();
let updated = close_issue(dir.path(), "def99999").unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&updated).unwrap();
+3 -4
View File
@@ -1,6 +1,5 @@
pub mod issues;
pub mod repo_store;
pub mod smart_http;
pub mod store;
pub mod smart_http;
pub mod issues;
pub mod tigris;
pub mod visibility_pack;
pub mod repo_store;
+23 -77
View File
@@ -33,16 +33,6 @@ pub struct RepoStore {
}
impl RepoStore {
#[cfg(test)]
pub fn for_testing(repos_dir: PathBuf, pool: PgPool) -> Self {
Self {
repos_dir,
tigris: None,
pool,
migrated: Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new())),
}
}
pub fn new(repos_dir: PathBuf, tigris: Option<TigrisClient>, pool: PgPool) -> Self {
Self {
repos_dir,
@@ -130,19 +120,10 @@ impl RepoStore {
if let Some(ref tigris) = self.tigris {
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
debug!(repo = %repo_name, "acquire_fresh: downloading latest from tigris");
if let Err(e) = tigris.download(&owner_slug, repo_name, &local_path).await {
// The Tigris archive is present (HEAD ok) but unreadable — a
// corrupt/partial upload, or a transient GET failure. If we have a
// valid local copy, proceed with it rather than blocking the write;
// the post-write upload re-syncs (self-heals) Tigris. Only hard-fail
// when there is no local copy to fall back to.
if local_path.exists() {
warn!(repo = %repo_name, err = %e,
"acquire_fresh: tigris download failed — falling back to local copy");
return Ok(local_path);
}
return Err(e).context("downloading repo from tigris (fresh)");
}
tigris
.download(&owner_slug, repo_name, &local_path)
.await
.context("downloading repo from tigris (fresh)")?;
return Ok(local_path);
}
}
@@ -183,17 +164,10 @@ impl RepoStore {
if let Some(ref tigris) = self.tigris {
if tigris.exists(&owner_slug, repo_name).await.unwrap_or(false) {
debug!(repo = %repo_name, "write acquire: downloading latest from tigris");
if let Err(e) = tigris.download(&owner_slug, repo_name, &local_path).await {
// Same self-healing fallback as acquire_fresh: a corrupt/unreadable
// Tigris archive must not block a write when a valid local copy
// exists — release(success) will re-upload a good archive.
if local_path.exists() {
warn!(repo = %repo_name, err = %e,
"write acquire: tigris download failed — falling back to local copy");
} else {
return Err(e).context("downloading repo from tigris for write");
}
}
tigris
.download(&owner_slug, repo_name, &local_path)
.await
.context("downloading repo from tigris for write")?;
}
}
@@ -248,23 +222,20 @@ impl RepoStore {
/// Compute the local disk path and owner slug for a repo.
///
/// Three-layer defence against path traversal:
/// 1. Strict allowlist on `owner_did` and `repo_name` (no `..`, slashes,
/// null bytes, leading dots; length-bounded).
/// 2. The joined path must remain rooted at `repos_dir`.
/// 3. Every component of the joined path must be `Component::Normal`
/// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir`
/// segment is rejected. This is the CodeQL-recognised barrier
/// pattern for `rust/path-injection`.
/// Rejects any input that could be used for path traversal — strict
/// allowlist on both `owner_did` and `repo_name`, length-bounded, and
/// the resulting path must remain inside `repos_dir`.
fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> {
validate_path_components(owner_did, repo_name)?;
let owner_slug = owner_did.replace([':', '/'], "_");
let owner_slug = owner_did.replace(':', "_").replace('/', "_");
let local_path = self
.repos_dir
.join(&owner_slug)
.join(format!("{repo_name}.git"));
// Defence in depth: even though both inputs are allowlisted, verify
// the joined path is still rooted at repos_dir.
if !local_path.starts_with(&self.repos_dir) {
anyhow::bail!(
"computed repo path escaped repos_dir: {}",
@@ -272,23 +243,6 @@ impl RepoStore {
);
}
// Explicit component walk — sanitisation barrier that static analysers
// (CodeQL `rust/path-injection`) recognise. The path must be composed
// entirely of Normal segments after the root prefix; any ParentDir or
// CurDir component is a traversal attempt.
for component in local_path.components() {
use std::path::Component;
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {}
Component::ParentDir => {
anyhow::bail!("path contains parent-directory component");
}
Component::CurDir => {
anyhow::bail!("path contains current-directory component");
}
}
}
Ok((owner_slug, local_path))
}
}
@@ -364,24 +318,16 @@ impl RepoWriteGuard {
&self.local_path
}
/// Upload to Tigris (only when the write succeeded) and release the advisory
/// lock. Pass `success = false` when the write operation failed — uploading a
/// half-applied or otherwise inconsistent repo would propagate corruption to
/// Tigris (and to every node that later downloads it). The lock is always
/// released regardless, to avoid stale locks blocking future writes.
pub async fn release(self, success: bool) {
// Upload to Tigris only on success.
if success {
if let Some(ref tigris) = self.tigris {
if let Err(e) = tigris
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
.await
{
warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
}
/// Upload to Tigris and release the advisory lock. Call this when the write is done.
pub async fn release(self) {
// Upload to Tigris
if let Some(ref tigris) = self.tigris {
if let Err(e) = tigris
.upload(&self.owner_slug, &self.repo_name, &self.local_path)
.await
{
warn!(repo = %self.repo_name, err = %e, "failed to upload repo to tigris after write");
}
} else {
warn!(repo = %self.repo_name, "write failed — skipping tigris upload to avoid propagating an inconsistent repo");
}
// Release advisory lock
+3 -572
View File
@@ -1,13 +1,12 @@
use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Stdio;
use axum::body::Body;
use axum::http::StatusCode;
use axum::response::Response;
use bytes::Bytes;
use std::collections::HashSet;
use std::path::Path;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use anyhow::{bail, Result};
/// Handle `GET /:owner/:repo/info/refs?service=git-upload-pack`
/// or `?service=git-receive-pack`
@@ -121,571 +120,3 @@ fn pkt_line(data: &str) -> Vec<u8> {
let len = data.len() + 4;
format!("{len:04x}{data}").into_bytes()
}
/// Build a packfile containing every object reachable from all refs EXCEPT the
/// given blob OIDs. Commits and trees are always included, so SHAs stay intact;
/// only the named blobs are dropped.
pub fn build_filtered_pack(repo_path: &Path, withheld: &HashSet<String>) -> Result<Vec<u8>> {
// All reachable objects as "oid [path]" lines.
let rev = std::process::Command::new("git")
.args(["rev-list", "--objects", "--all"])
.current_dir(repo_path)
.output()?;
if !rev.status.success() {
bail!(
"git rev-list failed: {}",
String::from_utf8_lossy(&rev.stderr)
);
}
let mut keep = Vec::new();
for line in String::from_utf8_lossy(&rev.stdout).lines() {
let oid = line.split_whitespace().next().unwrap_or("");
if oid.is_empty() || withheld.contains(oid) {
continue;
}
keep.push(oid.to_string());
}
let mut child = std::process::Command::new("git")
.args(["pack-objects", "--stdout"])
.current_dir(repo_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
{
use std::io::Write as _;
let mut stdin = child.stdin.take().expect("stdin");
stdin.write_all(keep.join("\n").as_bytes())?;
stdin.write_all(b"\n")?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
bail!(
"git pack-objects failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(out.stdout)
}
/// Serve a clone/fetch with the withheld blobs removed from the response pack.
///
/// The framing is git protocol v0 (`NAK` then the pack), matching the v0 ref
/// advertisement that `info_refs` emits (it runs `git upload-pack
/// --advertise-refs` without `GIT_PROTOCOL=version=2`, so clients negotiate v0).
/// If `info_refs` ever advertises v2, this serve path must learn v2 framing too.
///
/// Because the pack deliberately omits blobs that the sent trees still
/// reference, the pack is not closed under reachability. A stock full clone
/// rejects it at fetch time ("remote did not send all necessary objects"); only
/// a partial clone (the client passes `--filter`, marking a promisor remote)
/// accepts the pack with the private blobs absent. Tree and commit SHAs stay
/// intact either way. The clean partial-clone client UX is a separate follow-up
/// (git-remote-gitlawb); the security guarantee (private bytes never leave the
/// node) holds regardless of client.
///
/// Negotiation is intentionally ignored: rather than honoring the client's
/// `want`/`have` lines, this always sends a self-contained pack of every object
/// across all refs minus the withheld blobs, and replies `NAK`. A fresh clone
/// and an incremental fetch are both correct (the client de-duplicates objects
/// it already has); the cost is that a fetch re-sends the full object set
/// instead of a thin delta. Honoring negotiation for smaller fetch packs is an
/// optimization follow-up, not a correctness requirement.
pub async fn upload_pack_excluding(
repo_path: &Path,
request_body: Bytes,
withheld: &HashSet<String>,
) -> Result<Response> {
// build_filtered_pack shells out to git (rev-list, pack-objects) with
// blocking std::process I/O; run it off the async worker so a large repo's
// pack build does not stall the tokio runtime.
let pack = {
let repo_path = repo_path.to_path_buf();
let withheld = withheld.clone();
tokio::task::spawn_blocking(move || build_filtered_pack(&repo_path, &withheld))
.await
.context("filtered-pack build task panicked")??
};
// The client lists its capabilities on the first `want` line. Honor
// side-band-64k when offered (every modern smart-HTTP client offers it);
// otherwise stream the raw pack after NAK.
let sideband = memmem(&request_body, b"side-band-64k");
let mut body = Vec::new();
body.extend_from_slice(&pkt_line("NAK\n"));
if sideband {
// Band 1 carries pack data, chunked under the pkt-line size limit.
for chunk in pack.chunks(65515) {
let mut framed = Vec::with_capacity(chunk.len() + 1);
framed.push(0x01);
framed.extend_from_slice(chunk);
let len = framed.len() + 4;
body.extend_from_slice(format!("{len:04x}").as_bytes());
body.extend_from_slice(&framed);
}
body.extend_from_slice(b"0000");
} else {
body.extend_from_slice(&pack);
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/x-git-upload-pack-result")
.header("Cache-Control", "no-cache")
.body(Body::from(body))?)
}
/// True if `needle` occurs anywhere in `haystack`. Small substring scan used to
/// detect a client capability token in the upload-pack request body.
fn memmem(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return needle.is_empty();
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
/// List OIDs in a pack by writing it to a temp dir and running verify-pack.
pub(super) fn pack_object_ids(pack: &[u8]) -> std::collections::HashSet<String> {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.pack");
std::fs::write(&path, pack).unwrap();
// index-pack creates the matching .idx next to the pack.
let ok = Command::new("git")
.args(["index-pack", path.to_str().unwrap()])
.status()
.unwrap()
.success();
assert!(ok, "index-pack failed");
let out = Command::new("git")
.args(["verify-pack", "-v", path.to_str().unwrap()])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_whitespace().next())
.filter(|t| t.len() == 40 && t.chars().all(|c| c.is_ascii_hexdigit()))
.map(|s| s.to_string())
.collect()
}
#[tokio::test]
async fn filtered_serve_excludes_withheld_blob() {
// Build a bare repo, capture the secret + public blob OIDs.
let td = TempDir::new().unwrap();
let work = td.path().join("work");
let bare = td.path().join("bare.git");
let g = |args: &[&str], dir: &std::path::Path| {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
};
std::fs::create_dir_all(work.join("secret")).unwrap();
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::write(work.join("public/a.txt"), b"pub\n").unwrap();
std::fs::write(work.join("secret/b.txt"), b"SECRET\n").unwrap();
g(&["init", "-q"], &work);
g(&["config", "user.email", "t@t"], &work);
g(&["config", "user.name", "t"], &work);
g(&["add", "."], &work);
g(&["commit", "-qm", "init"], &work);
let oid = |p: &str| {
let o = Command::new("git")
.args(["rev-parse", &format!("HEAD:{p}")])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
let secret = oid("secret/b.txt");
let public = oid("public/a.txt");
g(
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
let mut withheld = std::collections::HashSet::new();
withheld.insert(secret.clone());
let pack = build_filtered_pack(&bare, &withheld).unwrap();
let ids = pack_object_ids(&pack);
assert!(ids.contains(&public), "public blob must be in the pack");
assert!(
!ids.contains(&secret),
"secret blob must NOT be in the pack"
);
}
#[tokio::test]
async fn client_clone_lacks_withheld_blob_bytes() {
use axum::body::to_bytes;
let td = TempDir::new().unwrap();
let work = td.path().join("work");
let bare = td.path().join("bare.git");
let g = |args: &[&str], dir: &std::path::Path| {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
};
std::fs::create_dir_all(work.join("secret")).unwrap();
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::write(work.join("public/a.txt"), b"pub\n").unwrap();
std::fs::write(work.join("secret/b.txt"), b"SECRET\n").unwrap();
g(&["init", "-q"], &work);
g(&["config", "user.email", "t@t"], &work);
g(&["config", "user.name", "t"], &work);
g(&["add", "."], &work);
g(&["commit", "-qm", "init"], &work);
let oid = |p: &str| {
let o = Command::new("git")
.args(["rev-parse", &format!("HEAD:{p}")])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
let secret_oid = oid("secret/b.txt");
let public_oid = oid("public/a.txt");
g(
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
let mut withheld = std::collections::HashSet::new();
withheld.insert(secret_oid.clone());
// A realistic v0 request advertises side-band-64k, so the serve frames
// the pack in band 1 (the path real clients exercise).
let req = Bytes::from_static(
b"0098want 0000000000000000000000000000000000000000 \
side-band-64k ofs-delta agent=git/2\n00000009done\n",
);
let resp = upload_pack_excluding(&bare, req, &withheld).await.unwrap();
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let ids = pack_object_ids(&extract_pack(&body));
assert!(
ids.contains(&public_oid),
"public blob must be present in served pack"
);
assert!(
!ids.contains(&secret_oid),
"withheld blob must be absent from served pack"
);
}
/// Strip the v0 upload-pack framing (NAK line + sideband-64k bands),
/// returning the raw pack. Mirrors how a client de-frames the band-1 stream.
fn extract_pack(body: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i + 4 <= body.len() {
let len =
usize::from_str_radix(std::str::from_utf8(&body[i..i + 4]).unwrap_or("0000"), 16)
.unwrap_or(0);
if len == 0 {
i += 4;
continue;
}
let chunk = &body[i + 4..i + len];
// band 1 = pack data; skip the NAK line and any other bands.
if chunk.first() == Some(&0x01) {
out.extend_from_slice(&chunk[1..]);
}
i += len;
}
out
}
// Shared harness for the real-git server tests: a minimal smart-HTTP server
// backed by the real info_refs + upload_pack_excluding.
#[derive(Clone)]
struct FilterState {
repo: std::path::PathBuf,
withheld: HashSet<String>,
}
async fn refs_handler(
axum::extract::State(st): axum::extract::State<std::sync::Arc<FilterState>>,
axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let service = q.get("service").cloned().unwrap_or_default();
info_refs(&st.repo, &service).await.unwrap()
}
async fn pack_handler(
axum::extract::State(st): axum::extract::State<std::sync::Arc<FilterState>>,
body: Bytes,
) -> Response {
upload_pack_excluding(&st.repo, body, &st.withheld)
.await
.unwrap()
}
/// Spawn the server for `bare`, withholding `withheld`. Returns the clone URL
/// and the server task (abort it when done).
async fn spawn_filter_server(
bare: std::path::PathBuf,
withheld: HashSet<String>,
) -> (String, tokio::task::JoinHandle<()>) {
use axum::routing::{get, post};
let state = std::sync::Arc::new(FilterState {
repo: bare,
withheld,
});
let app = axum::Router::new()
.route("/repo.git/info/refs", get(refs_handler))
.route("/repo.git/git-upload-pack", post(pack_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://127.0.0.1:{port}/repo.git"), handle)
}
fn run_git(args: &[&str], dir: &std::path::Path) {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
}
/// Build a work repo (public/a.txt, secret/b.txt) and a bare clone of it.
/// Returns (work, bare, secret_blob_oid, public_blob_oid).
fn fixture_with_secret(
td: &TempDir,
) -> (std::path::PathBuf, std::path::PathBuf, String, String) {
let work = td.path().join("work");
let bare = td.path().join("bare.git");
std::fs::create_dir_all(work.join("secret")).unwrap();
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::write(work.join("public/a.txt"), b"pub\n").unwrap();
std::fs::write(work.join("secret/b.txt"), b"SECRET\n").unwrap();
run_git(&["init", "-q"], &work);
run_git(&["config", "user.email", "t@t"], &work);
run_git(&["config", "user.name", "t"], &work);
run_git(&["add", "."], &work);
run_git(&["commit", "-qm", "init"], &work);
let oid = |p: &str| {
let o = Command::new("git")
.args(["rev-parse", &format!("HEAD:{p}")])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
let secret_oid = oid("secret/b.txt");
let public_oid = oid("public/a.txt");
run_git(
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
(work, bare, secret_oid, public_oid)
}
/// Enumerate exactly the objects a repo physically has (no promisor lazy
/// fetch), so tests assert on what bytes actually crossed the wire.
fn local_object_ids(repo: &std::path::Path) -> String {
let out = Command::new("git")
.args(["cat-file", "--batch-all-objects", "--batch-check"])
.current_dir(repo)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
/// End-to-end: a real `git` client clones through `info_refs` +
/// `upload_pack_excluding` and ends up without the withheld blob's bytes
/// while still seeing its tree entry (SHA). Uses a partial clone
/// (`--filter`) because a pack that omits a referenced blob is only
/// accepted by a promisor-aware client; a stock full clone is refused at
/// fetch time by the connectivity check.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn real_git_partial_clone_omits_withheld_blob() {
let td = TempDir::new().unwrap();
let (_work, bare, secret_oid, public_oid) = fixture_with_secret(&td);
let (url, server) = spawn_filter_server(bare, HashSet::from([secret_oid.clone()])).await;
let dest = td.path().join("clone");
let dest_s = dest.to_str().unwrap().to_string();
let out = tokio::task::spawn_blocking(move || {
Command::new("git")
.args([
"-c",
"protocol.version=2",
"clone",
"--filter=blob:none",
"--no-checkout",
"-q",
&url,
&dest_s,
])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
out.status.success(),
"clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// The public blob is present in the clone, the withheld blob is not.
let local = local_object_ids(&dest);
assert!(
local.contains(&public_oid),
"public blob should be present in the clone"
);
assert!(
!local.contains(&secret_oid),
"withheld blob bytes must be absent from the clone"
);
// The tree entry (and SHA) for the private file is still visible.
let tree = Command::new("git")
.args(["ls-tree", "-r", "HEAD"])
.current_dir(&dest)
.output()
.unwrap();
let tree = String::from_utf8_lossy(&tree.stdout);
assert!(
tree.contains(&secret_oid) && tree.contains("secret/b.txt"),
"the private path and its blob SHA must remain visible: {tree}"
);
server.abort();
}
/// End-to-end: an incremental `git fetch` after a partial clone still works
/// and still withholds the private blob. The serve path ignores the client's
/// have/want negotiation and always sends a self-contained pack of all refs
/// minus the withheld blobs (it replies NAK, so the client treats it as "no
/// common commits" and accepts the full set). This is correct, just not
/// bandwidth-optimal; thin-pack/negotiation is an optimization follow-up.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn real_git_fetch_after_partial_clone_still_withholds() {
let td = TempDir::new().unwrap();
let (work, bare, secret_oid, _public_oid) = fixture_with_secret(&td);
let branch = {
let o = Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
let (url, server) =
spawn_filter_server(bare.clone(), HashSet::from([secret_oid.clone()])).await;
// Partial-clone the initial state.
let dest = td.path().join("clone");
let dest_s = dest.to_str().unwrap().to_string();
let url_c = url.clone();
let out = tokio::task::spawn_blocking(move || {
Command::new("git")
.args([
"-c",
"protocol.version=2",
"clone",
"--filter=blob:none",
"--no-checkout",
"-q",
&url_c,
&dest_s,
])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
out.status.success(),
"clone failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Add a new public commit on the server side.
std::fs::write(work.join("public/c.txt"), b"v2\n").unwrap();
run_git(&["add", "."], &work);
run_git(&["commit", "-qm", "c2"], &work);
let new_oid = {
let o = Command::new("git")
.args(["rev-parse", "HEAD:public/c.txt"])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
run_git(&["push", "-q", bare.to_str().unwrap(), &branch], &work);
// Incremental fetch: the client has c1 and asks for the update.
let dest_f = dest.clone();
let out = tokio::task::spawn_blocking(move || {
Command::new("git")
.args(["-c", "protocol.version=2", "fetch", "-q", "origin"])
.current_dir(&dest_f)
.output()
.unwrap()
})
.await
.unwrap();
assert!(
out.status.success(),
"fetch failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// The new commit's blob arrived; the withheld blob is still absent.
let local = local_object_ids(&dest);
assert!(
local.contains(&new_oid),
"the new commit's blob must be fetched"
);
assert!(
!local.contains(&secret_oid),
"withheld blob must remain absent after fetch"
);
server.abort();
}
}
+13 -42
View File
@@ -1,6 +1,6 @@
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, Context, bail};
/// Initialize a new bare git repository with SHA-1 object format (default).
///
@@ -107,8 +107,7 @@ pub fn resolve_head(repo_path: &Path, preferred_branch: &str) -> String {
// 3. Walk all refs — prefer main/master, then take the first one
if let Ok(refs) = list_refs(repo_path) {
let branches: Vec<_> = refs
.iter()
let branches: Vec<_> = refs.iter()
.filter(|(r, _)| r.starts_with("refs/heads/"))
.collect();
// Preferred names in order
@@ -200,13 +199,7 @@ pub fn ls_tree(repo_path: &Path, refname: &str, tree_path: &str) -> Result<Vec<T
let kind = parts.next()?.to_string();
let hash = parts.next()?.to_string();
let size: Option<u64> = parts.next().and_then(|s| s.parse().ok());
Some(TreeEntry {
mode,
kind,
hash,
path: name.to_string(),
size,
})
Some(TreeEntry { mode, kind, hash, path: name.to_string(), size })
})
.collect();
@@ -246,9 +239,7 @@ pub struct CommitInfo {
fn serialize_timestamp<S: serde::Serializer>(ts: &i64, s: S) -> Result<S::Ok, S::Error> {
use chrono::TimeZone;
let dt = chrono::Utc
.timestamp_opt(*ts, 0)
.single()
let dt = chrono::Utc.timestamp_opt(*ts, 0).single()
.unwrap_or_else(chrono::Utc::now);
s.serialize_str(&dt.to_rfc3339())
}
@@ -284,9 +275,7 @@ pub fn read_object(repo_path: &Path, sha256_hex: &str) -> Result<Option<(String,
return Ok(None);
}
let obj_type = String::from_utf8_lossy(&type_output.stdout)
.trim()
.to_string();
let obj_type = String::from_utf8_lossy(&type_output.stdout).trim().to_string();
// Read the raw content bytes
let content_output = Command::new("git")
@@ -316,13 +305,7 @@ pub fn branch_diff(repo_path: &Path, target_branch: &str, source_branch: &str) -
/// Merge source_branch into target_branch in a bare repo using a temporary worktree.
/// Returns the new merge commit hash.
pub fn merge_branch(
repo_path: &Path,
target_branch: &str,
source_branch: &str,
author_did: &str,
pr_title: &str,
) -> Result<String> {
pub fn merge_branch(repo_path: &Path, target_branch: &str, source_branch: &str, author_did: &str, pr_title: &str) -> Result<String> {
let worktree_path = repo_path.join("_merge_worktree");
// Clean up any leftover worktree
@@ -341,24 +324,13 @@ pub fn merge_branch(
.output()
.context("failed to create worktree")?;
if !wt.status.success() {
bail!(
"git worktree add failed: {}",
String::from_utf8_lossy(&wt.stderr)
);
bail!("git worktree add failed: {}", String::from_utf8_lossy(&wt.stderr));
}
// Run merge in worktree
let merge = Command::new("git")
.args([
"merge",
"--no-ff",
source_branch,
"-m",
&format!(
"Merge branch '{}' into {} ({})",
source_branch, target_branch, pr_title
),
])
.args(["merge", "--no-ff", source_branch, "-m",
&format!("Merge branch '{}' into {} ({})", source_branch, target_branch, pr_title)])
.current_dir(&worktree_path)
.env("GIT_AUTHOR_NAME", author_did)
.env("GIT_AUTHOR_EMAIL", format!("{}@gitlawb", author_did))
@@ -377,10 +349,7 @@ pub fn merge_branch(
let _ = std::fs::remove_dir_all(&worktree_path);
if !success {
bail!(
"git merge failed: {}",
String::from_utf8_lossy(&merge.stderr)
);
bail!("git merge failed: {}", String::from_utf8_lossy(&merge.stderr));
}
// Get new HEAD of target branch
@@ -396,6 +365,8 @@ pub fn merge_branch(
/// Resolve a repo disk path: {repos_dir}/{owner_slug}/{repo_name}.git
pub fn repo_disk_path(repos_dir: &Path, owner_did: &str, repo_name: &str) -> PathBuf {
// Sanitize the DID for use as a directory name
let owner_slug = owner_did.replace([':', '/'], "_");
let owner_slug = owner_did
.replace(':', "_")
.replace('/', "_");
repos_dir.join(owner_slug).join(format!("{repo_name}.git"))
}
+15 -62
View File
@@ -23,10 +23,7 @@ impl TigrisClient {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let s3 = S3Client::new(&config);
info!(bucket = %bucket, "tigris storage client initialized");
Ok(Self {
s3,
bucket: bucket.to_string(),
})
Ok(Self { s3, bucket: bucket.to_string() })
}
/// S3 key for a given repo: `repos/v1/{owner_slug}/{repo_name}.tar.zst`
@@ -37,9 +34,7 @@ impl TigrisClient {
/// Check if a repo archive exists in Tigris.
pub async fn exists(&self, owner_slug: &str, repo_name: &str) -> Result<bool> {
let key = Self::repo_key(owner_slug, repo_name);
match self
.s3
.head_object()
match self.s3.head_object()
.bucket(&self.bucket)
.key(&key)
.send()
@@ -47,7 +42,7 @@ impl TigrisClient {
{
Ok(_) => Ok(true),
Err(e) => {
if e.as_service_error().is_some_and(|e| e.is_not_found()) {
if e.as_service_error().map_or(false, |e| e.is_not_found()) {
Ok(false)
} else {
Err(anyhow::anyhow!("tigris HEAD {key}: {e}"))
@@ -72,8 +67,7 @@ impl TigrisClient {
let body = aws_sdk_s3::primitives::ByteStream::from(archive_bytes);
self.s3
.put_object()
self.s3.put_object()
.bucket(&self.bucket)
.key(&key)
.body(body)
@@ -87,28 +81,18 @@ impl TigrisClient {
}
/// Download a repo archive from Tigris and extract to local disk.
pub async fn download(
&self,
owner_slug: &str,
repo_name: &str,
local_path: &Path,
) -> Result<()> {
pub async fn download(&self, owner_slug: &str, repo_name: &str, local_path: &Path) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
debug!(key = %key, path = %local_path.display(), "downloading repo from tigris");
let resp = self
.s3
.get_object()
let resp = self.s3.get_object()
.bucket(&self.bucket)
.key(&key)
.send()
.await
.context(format!("tigris GET {key}"))?;
let data = resp
.body
.collect()
.await
let data = resp.body.collect().await
.context("reading tigris response body")?
.into_bytes()
.to_vec();
@@ -130,8 +114,7 @@ impl TigrisClient {
#[allow(dead_code)]
pub async fn delete(&self, owner_slug: &str, repo_name: &str) -> Result<()> {
let key = Self::repo_key(owner_slug, repo_name);
self.s3
.delete_object()
self.s3.delete_object()
.bucket(&self.bucket)
.key(&key)
.send()
@@ -157,46 +140,16 @@ fn compress_repo(repo_path: &Path) -> Result<Vec<u8>> {
}
/// Decompress a tar.zst byte vector into a local directory.
///
/// Extraction is atomic with respect to `local_path`: the archive is unpacked
/// into a sibling temp directory first, and only swapped into place once it
/// fully succeeds. A corrupt or truncated archive therefore can never clobber a
/// good existing copy at `local_path` — on failure we discard the temp dir and
/// leave `local_path` exactly as it was.
fn decompress_repo(data: &[u8], local_path: &Path) -> Result<()> {
let parent = local_path.parent().context("repo path has no parent")?;
std::fs::create_dir_all(parent).context("creating parent dir")?;
let file_name = local_path
.file_name()
.context("repo path has no file name")?
.to_string_lossy();
let tmp_dir = parent.join(format!(".{file_name}.tmp-extract"));
// Clear any leftover temp dir from a previously-interrupted extraction.
let _ = std::fs::remove_dir_all(&tmp_dir);
std::fs::create_dir_all(&tmp_dir).context("creating temp extract dir")?;
// Unpack into the temp dir; on any failure, clean up and bail without
// touching local_path.
let unpack = (|| -> Result<()> {
let decoder = zstd::stream::Decoder::new(data)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(&tmp_dir).context("unpacking tar.zst")?;
Ok(())
})();
if let Err(e) = unpack {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e);
// Ensure parent directory exists
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent).context("creating parent dir")?;
}
std::fs::create_dir_all(local_path).context("creating repo dir")?;
// Swap the freshly-extracted repo into place. rename within the same parent
// is effectively atomic, but most platforms refuse to rename onto a
// non-empty dir, so remove the old copy first.
if local_path.exists() {
std::fs::remove_dir_all(local_path).context("removing stale repo dir")?;
}
std::fs::rename(&tmp_dir, local_path).context("swapping extracted repo into place")?;
let decoder = zstd::stream::Decoder::new(data)?;
let mut archive = tar::Archive::new(decoder);
archive.unpack(local_path).context("unpacking tar.zst")?;
Ok(())
}
@@ -1,233 +0,0 @@
//! Resolve which blob OIDs must be withheld from a caller because every path
//! at which the blob appears is denied by the repo's visibility rules. Trees
//! and commits are never withheld (mode B keeps SHAs intact); only blob
//! content is held back.
use crate::db::VisibilityRule;
use crate::git::store;
use crate::visibility::{visibility_check, Decision};
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::path::Path;
/// List every (blob_oid, "/repo/relative/path") pair reachable from any branch
/// ref in `repo_path`. Uses `git ls-tree -r` per ref so each path a blob lives
/// at is represented (the same blob content can appear at several paths). Paths
/// are returned with a leading "/" to match the glob form used by visibility
/// rules ("/secret/**").
fn blob_paths(repo_path: &Path) -> Result<Vec<(String, String)>> {
let refs = store::list_refs(repo_path).context("list_refs failed")?;
let mut out = Vec::new();
for (refname, _oid) in refs {
if !refname.starts_with("refs/heads/") && !refname.starts_with("refs/tags/") {
continue;
}
let listing = std::process::Command::new("git")
.args(["ls-tree", "-r", &refname])
.current_dir(repo_path)
.output()
.context("git ls-tree -r failed")?;
if !listing.status.success() {
continue;
}
for line in String::from_utf8_lossy(&listing.stdout).lines() {
// "<mode> blob <oid>\t<path>"
let Some((meta, path)) = line.split_once('\t') else {
continue;
};
let mut parts = meta.split_whitespace();
let _mode = parts.next();
let kind = parts.next();
let oid = parts.next();
if kind == Some("blob") {
if let Some(oid) = oid {
out.push((oid.to_string(), format!("/{path}")));
}
}
}
}
Ok(out)
}
/// Blob OIDs the caller may not read. A blob is withheld only if visibility
/// denies the caller at *every* path the blob appears at; a blob that is also
/// reachable through an allowed path is sent (its content is public elsewhere).
///
/// The whole-repo "/" gate is handled by the caller before this function runs:
/// if "/" denies, the caller gets a 404 and never reaches the filtered serve.
pub fn withheld_blob_oids(
repo_path: &Path,
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Result<HashSet<String>> {
let mut denied: HashSet<String> = HashSet::new();
let mut allowed: HashSet<String> = HashSet::new();
for (oid, path) in blob_paths(repo_path)? {
match visibility_check(rules, is_public, owner_did, caller, &path) {
Decision::Deny => {
denied.insert(oid);
}
Decision::Allow => {
allowed.insert(oid);
}
}
}
Ok(denied.difference(&allowed).cloned().collect())
}
/// Objects that may replicate to the public: everything not in `withheld`.
/// Order-preserving. The single seam every replication site (IPFS, Pinata)
/// passes its object list through; option B would later reroute the withheld
/// ones through encrypt-then-pin instead of dropping them.
pub fn replicable_objects(all: Vec<String>, withheld: &HashSet<String>) -> Vec<String> {
all.into_iter()
.filter(|oid| !withheld.contains(oid))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::VisibilityMode;
use chrono::Utc;
use std::process::Command;
use tempfile::TempDir;
fn rule(path_glob: &str, readers: &[&str]) -> VisibilityRule {
VisibilityRule {
id: "x".into(),
repo_id: "r1".into(),
path_glob: path_glob.into(),
mode: VisibilityMode::B,
reader_dids: readers.iter().map(|s| s.to_string()).collect(),
created_by: "did:key:zOwner".into(),
created_at: Utc::now(),
}
}
const OWNER: &str = "did:key:zOwner";
/// Build a bare repo with public/a.txt and secret/b.txt at one commit.
/// Returns (tempdir, bare_path, secret_blob_oid, public_blob_oid).
fn fixture() -> (TempDir, std::path::PathBuf, String, String) {
let td = TempDir::new().unwrap();
let work = td.path().join("work");
let bare = td.path().join("bare.git");
let run = |args: &[&str], dir: &Path| {
let ok = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed");
};
std::fs::create_dir_all(work.join("public")).unwrap();
std::fs::create_dir_all(work.join("secret")).unwrap();
std::fs::write(work.join("public/a.txt"), b"public bytes\n").unwrap();
std::fs::write(work.join("secret/b.txt"), b"TOP SECRET\n").unwrap();
run(&["init", "-q"], &work);
run(&["config", "user.email", "t@t"], &work);
run(&["config", "user.name", "t"], &work);
run(&["add", "."], &work);
run(&["commit", "-qm", "init"], &work);
let oid = |path: &str| {
let out = Command::new("git")
.args(["rev-parse", &format!("HEAD:{path}")])
.current_dir(&work)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let secret = oid("secret/b.txt");
let public = oid("public/a.txt");
run(
&[
"clone",
"-q",
"--bare",
work.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
(td, bare, secret, public)
}
#[test]
fn anonymous_caller_withholds_only_private_blob() {
let (_td, bare, secret_oid, public_oid) = fixture();
let rules = [rule("/secret/**", &[])];
// caller = None models the public / any peer: what must not replicate.
let withheld = withheld_blob_oids(&bare, &rules, true, OWNER, None).unwrap();
assert!(
withheld.contains(&secret_oid),
"secret blob must be withheld"
);
assert!(
!withheld.contains(&public_oid),
"public blob must replicate"
);
// Trees and commits are never withheld; the set holds only the secret blob.
assert_eq!(withheld.len(), 1, "only the secret blob OID is withheld");
}
#[test]
fn non_reader_withholds_only_the_private_blob() {
let (_td, bare, secret, public) = fixture();
let rules = [rule("/secret/**", &["did:key:zFriend"])];
let withheld =
withheld_blob_oids(&bare, &rules, true, OWNER, Some("did:key:zStranger")).unwrap();
assert!(withheld.contains(&secret), "secret blob must be withheld");
assert!(
!withheld.contains(&public),
"public blob must NOT be withheld"
);
}
#[test]
fn owner_withholds_nothing() {
let (_td, bare, secret, public) = fixture();
let rules = [rule("/secret/**", &["did:key:zFriend"])];
let withheld = withheld_blob_oids(&bare, &rules, true, OWNER, Some(OWNER)).unwrap();
assert!(withheld.is_empty(), "owner sees everything");
let _ = (secret, public);
}
#[test]
fn listed_reader_withholds_nothing() {
let (_td, bare, _secret, _public) = fixture();
let rules = [rule("/secret/**", &["did:key:zFriend"])];
let withheld =
withheld_blob_oids(&bare, &rules, true, OWNER, Some("did:key:zFriend")).unwrap();
assert!(withheld.is_empty(), "listed reader sees the subtree");
}
#[test]
fn no_subtree_rules_withholds_nothing() {
let (_td, bare, _secret, _public) = fixture();
let withheld = withheld_blob_oids(&bare, &[], true, OWNER, None).unwrap();
assert!(
withheld.is_empty(),
"public repo, no rules, nothing withheld"
);
}
#[test]
fn replicable_objects_drops_withheld_keeps_rest() {
let all = vec!["aaa".to_string(), "bbb".to_string(), "ccc".to_string()];
let withheld: HashSet<String> = ["bbb".to_string()].into_iter().collect();
let got = replicable_objects(all, &withheld);
assert_eq!(got, vec!["aaa".to_string(), "ccc".to_string()]);
}
#[test]
fn replicable_objects_empty_withheld_keeps_all() {
let all = vec!["aaa".to_string(), "bbb".to_string()];
let withheld: HashSet<String> = HashSet::new();
let got = replicable_objects(all.clone(), &withheld);
assert_eq!(got, all);
}
}
+6 -3
View File
@@ -48,7 +48,8 @@ impl MutationRoot {
assignee_did: String,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.claim_task(&id, &assignee_did)
.await
@@ -71,7 +72,8 @@ impl MutationRoot {
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let task = db
.finish_task(&id, "completed", input.result.as_deref())
.await
@@ -94,7 +96,8 @@ impl MutationRoot {
input: FinishTaskInput,
) -> Result<AgentTaskType> {
let db = ctx.data_unchecked::<Arc<Db>>();
let tx = ctx.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let tx = ctx
.data_unchecked::<tokio::sync::broadcast::Sender<TaskEventBroadcast>>();
let reason = input.reason.unwrap_or_default();
let task = db
.finish_task(&id, "failed", Some(&reason))
@@ -1,5 +1,5 @@
use async_graphql::futures_util::Stream;
use async_graphql::{Context, Subscription};
use async_graphql::futures_util::Stream;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt as _;
@@ -22,7 +22,7 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let rf = repo.clone();
match r {
Ok(ev) if rf.as_deref().is_none_or(|r| ev.repo == r) => Some(RefUpdateType {
Ok(ev) if rf.as_deref().map_or(true, |r| ev.repo == r) => Some(RefUpdateType {
repo: ev.repo,
ref_name: ev.ref_name,
old_sha: ev.old_sha,
@@ -47,13 +47,15 @@ impl SubscriptionRoot {
BroadcastStream::new(rx).filter_map(move |r| {
let tf = task_id.clone();
match r {
Ok(ev) if tf.as_deref().is_none_or(|id| ev.task_id == id) => Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
}),
Ok(ev) if tf.as_deref().map_or(true, |id| ev.task_id == id) => {
Some(TaskEventType {
task_id: ev.task_id,
old_status: ev.old_status,
new_status: ev.new_status,
by_did: ev.by_did,
at: ev.at,
})
}
_ => None,
}
})
+1 -6
View File
@@ -7,8 +7,6 @@
//! If `ipfs_api` is empty the functions are no-ops, so the node works fine
//! without a local IPFS daemon.
use std::collections::HashSet;
use anyhow::Result;
use gitlawb_core::cid::Cid;
@@ -65,7 +63,7 @@ pub async fn pin_git_object(ipfs_api: &str, sha256_hex: &str, data: &[u8]) -> Re
let v: serde_json::Value = serde_json::from_str(line).ok()?;
v["Hash"].as_str().map(|s| s.to_string())
})
.next_back()
.last()
.unwrap_or(expected_cid.clone());
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
@@ -80,7 +78,6 @@ pub async fn pin_new_objects(
ipfs_api: &str,
repo_path: &std::path::Path,
db: &crate::db::Db,
withheld: &HashSet<String>,
) -> Vec<(String, String)> {
if ipfs_api.is_empty() {
return vec![];
@@ -95,8 +92,6 @@ pub async fn pin_new_objects(
}
};
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
let mut pinned = Vec::new();
for sha in object_list {
+63 -362
View File
@@ -1,7 +1,6 @@
mod api;
mod arweave;
mod auth;
mod bootstrap;
mod cert;
mod config;
mod db;
@@ -9,25 +8,20 @@ mod error;
mod git;
mod graphql;
mod ipfs_pin;
mod metrics;
mod operator;
mod p2p;
mod pinata;
mod rate_limit;
mod server;
mod state;
mod sync;
mod visibility;
mod webhooks;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tracing::{info, warn};
use tracing::info;
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use config::Config;
@@ -44,80 +38,33 @@ async fn main() -> Result<()> {
)
.init();
let mut config = Config::parse();
// Merge the embedded seed list of public network nodes into the runtime
// bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS.
bootstrap::merge_seeds(&mut config);
if !config.public_read {
warn!(
"GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha"
);
}
let config = Config::parse();
// Load or generate the node's identity keypair
let keypair = load_or_create_keypair(&config)?;
let node_did = keypair.did();
// One-time metrics init. Must run before any handler that calls into
// `metrics::record_*` so the registry exists when the first event fires.
// Safe to call even when GITLAWB_METRICS_ADDR is unset — those helpers
// are simply no-ops until something reads from the registry.
metrics::init(env!("CARGO_PKG_VERSION"), &node_did.to_string());
info!("╔══════════════════════════════════════════╗");
info!(
"║ gitlawb node v{} ║",
env!("CARGO_PKG_VERSION")
);
info!("║ gitlawb node v{} ║", env!("CARGO_PKG_VERSION"));
info!("╚══════════════════════════════════════════╝");
info!(did = %node_did, "node identity");
info!(addr = %config.bind_addr(), "listening");
// Process-wide shutdown signal. One sender lives in AppState (cloned
// into every handler); main() keeps a clone and flips it on SIGINT
// or SIGTERM. Tasks that hold a watch::Receiver get notified at
// their next await point.
let (shutdown_tx, _shutdown_rx_for_main) = watch::channel(false);
// Connect to PostgreSQL database
let db = Arc::new(
Db::connect(&config.database_url)
.await
.context("failed to connect to database")?,
);
// Prune peer rows that point back at this node (stale self-loop entries)
if let Some(public_url) = config.public_url.as_deref() {
match db.prune_self_peers(public_url).await {
Ok(0) => {}
Ok(n) => info!(removed = n, public_url, "pruned self-loop peer rows"),
Err(e) => warn!(err = %e, "prune_self_peers failed (non-fatal)"),
}
}
let db = Arc::new(Db::connect(&config.database_url)
.await
.context("failed to connect to database")?);
// Ensure repos directory exists
std::fs::create_dir_all(&config.repos_dir).context("failed to create repos directory")?;
std::fs::create_dir_all(&config.repos_dir)
.context("failed to create repos directory")?;
// Start libp2p swarm (if p2p_port > 0)
let p2p_handle = if config.p2p_port > 0 {
let bootstrap_addrs = config
.p2p_bootstrap
.iter()
let bootstrap_addrs = config.p2p_bootstrap.iter()
.filter_map(|s| s.parse().ok())
.collect();
let shutdown_rx = shutdown_tx.subscribe();
match p2p::start(
&node_did.to_string(),
config.p2p_port,
bootstrap_addrs,
Arc::clone(&db),
config.auto_sync,
shutdown_rx,
)
.await
{
match p2p::start(&node_did.to_string(), config.p2p_port, bootstrap_addrs, Arc::clone(&db), config.auto_sync).await {
Ok(handle) => {
info!(port = config.p2p_port, peer_id = %handle.local_peer_id, "libp2p swarm started");
Some(Arc::new(handle))
@@ -132,11 +79,9 @@ async fn main() -> Result<()> {
None
};
let http_client = Arc::new(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?,
);
let http_client = Arc::new(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?);
let (ref_update_tx, _) = tokio::sync::broadcast::channel::<state::RefUpdateBroadcast>(256);
let (task_event_tx, _) = tokio::sync::broadcast::channel::<state::TaskEventBroadcast>(256);
@@ -169,10 +114,11 @@ async fn main() -> Result<()> {
None
};
let repo_store =
git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone());
let rate_limiter = rate_limit::RateLimiter::new(10, std::time::Duration::from_secs(3600));
let repo_store = git::repo_store::RepoStore::new(
config.repos_dir.clone(),
tigris,
db.pool().clone(),
);
let state = AppState {
config: Arc::new(config.clone()),
@@ -186,112 +132,15 @@ async fn main() -> Result<()> {
graphql_schema,
machine_id,
repo_store,
rate_limiter,
shutdown_tx: shutdown_tx.clone(),
};
// Periodic peer-count poll for the metrics gauge. If p2p is disabled
// we still set the gauge to 0 so dashboards don't show "no data".
{
let p2p_for_metrics = state.p2p.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
loop {
tokio::select! {
_ = interval.tick() => {
let count = match &p2p_for_metrics {
Some(h) => h.status().await.map(|s| s.connected_peers).unwrap_or(0),
None => 0,
};
metrics::set_peers_connected(count as i64);
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
return;
}
}
}
}
});
}
// Spawn a task that flips the shutdown signal on SIGINT or SIGTERM.
// On Unix, both signals are handled. On Windows, only Ctrl-C is
// supported by tokio::signal::ctrl_c.
{
let tx = shutdown_tx.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
let mut sigterm =
unix_signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut sigint =
unix_signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => info!("SIGTERM received, shutting down"),
_ = sigint.recv() => info!("SIGINT received, shutting down"),
}
}
#[cfg(not(unix))]
{
use tokio::signal;
let _ = signal::ctrl_c().await;
info!("Ctrl-C received, shutting down");
}
tx.send(true).ok();
});
}
// Periodic cleanup of expired rate limit entries
{
let rl = state.rate_limiter.clone();
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
loop {
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(300)) => {
rl.cleanup().await;
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
}
}
});
}
let router = server::build_router(state.clone());
let listener = TcpListener::bind(config.bind_addr())
.await
let listener = TcpListener::bind(config.bind_addr()).await
.with_context(|| format!("failed to bind to {}", config.bind_addr()))?;
info!("✓ node started — did:{}", node_did);
info!(" repos dir: {}", config.repos_dir.display());
info!(
" database: PostgreSQL ({})",
&config.database_url.split('@').next_back().unwrap_or("?")
);
// Optional Prometheus metrics listener on a separate port.
let metrics_handle = if !config.metrics_addr.is_empty() {
match spawn_metrics_server(&config.metrics_addr, state.clone()).await {
Ok(handle) => {
info!(addr = %config.metrics_addr, "metrics endpoint listening");
Some(handle)
}
Err(e) => {
warn!(err = %e, addr = %config.metrics_addr, "failed to start metrics endpoint — continuing without");
None
}
}
} else {
info!("metrics endpoint disabled (GITLAWB_METRICS_ADDR not set)");
None
};
info!(" database: PostgreSQL ({})", &config.database_url.split('@').last().unwrap_or("?"));
// Publish our DID record to the Kademlia DHT shortly after startup
if let Some(p2p) = &state.p2p {
@@ -303,36 +152,26 @@ async fn main() -> Result<()> {
timestamp: chrono::Utc::now().to_rfc3339(),
};
let p2p_clone = Arc::clone(p2p);
let mut shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
// Small delay so Kademlia can find peers first
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
_ = shutdown_rx.changed() => return,
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
p2p_clone.put_did(did_record).await;
info!("DID record published to Kademlia DHT");
});
}
// Spawn background gossip: announce to bootstrap peers, then ping known peers periodically
{
if !config.bootstrap_peers.is_empty() || true {
let gossip_state = state.clone();
let bootstrap_peers = config.bootstrap_peers.clone();
let shutdown_rx = state.subscribe_shutdown();
tokio::spawn(async move {
gossip_task(gossip_state, bootstrap_peers, shutdown_rx).await;
gossip_task(gossip_state, bootstrap_peers).await;
});
}
// Start multi-node sync worker if auto_sync is enabled
if config.auto_sync {
sync::start(
Arc::clone(&state.db),
Arc::clone(&state.config),
Arc::clone(&state.node_keypair),
state.subscribe_shutdown(),
);
sync::start(Arc::clone(&state.db), Arc::clone(&state.config));
info!("auto-sync worker started");
}
@@ -341,18 +180,20 @@ async fn main() -> Result<()> {
&& !state.config.operator_private_key.is_empty()
{
match build_operator_client(&state.config, &state.node_did.to_string()) {
Ok(client) => match operator::startup_check(&client).await {
Ok(_) => {
let arc_client = Arc::new(client);
arc_client.spawn_heartbeat_loop(state.subscribe_shutdown());
}
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode operator check failed"));
Ok(client) => {
match operator::startup_check(&client).await {
Ok(_) => {
let arc_client = Arc::new(client);
arc_client.spawn_heartbeat_loop();
}
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode operator check failed"));
}
tracing::warn!(err = %e, "operator startup check failed — continuing without heartbeat loop");
}
tracing::warn!(err = %e, "operator startup check failed — continuing without heartbeat loop");
}
},
}
Err(e) => {
if state.config.operator_strict_mode {
return Err(e.context("strict-mode: failed to build operator client"));
@@ -364,94 +205,11 @@ async fn main() -> Result<()> {
info!("on-chain PoS disabled (GITLAWB_CONTRACT_NODE_STAKING or GITLAWB_OPERATOR_PRIVATE_KEY unset)");
}
// axum's `with_graceful_shutdown` waits for in-flight requests to
// complete (up to the configured grace) once the future resolves.
let shutdown_signal_for_axum = state.subscribe_shutdown();
let grace = std::time::Duration::from_secs(config.shutdown_grace_secs);
info!(grace_secs = config.shutdown_grace_secs, "axum server ready");
let serve_result = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let mut rx = shutdown_signal_for_axum;
// Wait until the watcher flips to true, then return so axum
// can begin draining.
while !*rx.borrow_and_update() {
if rx.changed().await.is_err() {
// Sender dropped — treat as shutdown.
break;
}
}
})
.await;
// Server has stopped accepting new connections and drained in-flight
// requests. Tear the rest of the system down.
info!("HTTP server stopped, beginning process shutdown");
if let Some(h) = metrics_handle {
h.abort();
}
let _ = grace; // recorded for operators in the log above; not enforced
serve_result?;
info!("clean exit");
axum::serve(listener, router).await?;
Ok(())
}
/// Spawn a small axum router that exposes only `GET /metrics` on its own
/// listener. Returns the JoinHandle so `main()` can abort it on shutdown.
/// This is deliberately separate from the main router so the metrics port
/// can be firewalled differently from the API port — bind to localhost
/// or a private interface only.
async fn spawn_metrics_server(addr: &str, state: AppState) -> Result<tokio::task::JoinHandle<()>> {
use axum::{response::IntoResponse, routing::get, Router};
async fn metrics_handler() -> impl IntoResponse {
match metrics::encode() {
Ok(body) => (
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
format!("metrics encode error: {e}"),
),
}
}
let mut shutdown_rx = state.subscribe_shutdown();
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("failed to bind metrics listener to {addr}"))?;
let app = Router::new().route("/metrics", get(metrics_handler));
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async move {
while !*shutdown_rx.borrow_and_update() {
if shutdown_rx.changed().await.is_err() {
break;
}
}
})
.await
{
warn!(err = %e, "metrics server exited with error");
}
});
Ok(handle)
}
fn build_operator_client(
config: &config::Config,
node_did: &str,
) -> Result<operator::OperatorClient> {
fn build_operator_client(config: &config::Config, node_did: &str) -> Result<operator::OperatorClient> {
use alloy::primitives::Address;
use std::str::FromStr;
@@ -470,21 +228,9 @@ fn build_operator_client(
}
/// Announce to bootstrap peers on startup, then periodically ping all known peers.
async fn gossip_task(
state: AppState,
bootstrap_peers: Vec<String>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
// If shutdown arrives during the initial delay, exit before announcing.
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("gossip: shutdown during startup delay, exiting");
return;
}
}
}
async fn gossip_task(state: AppState, bootstrap_peers: Vec<String>) {
// Small delay to let the HTTP server come up before we try to announce
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let client = reqwest::Client::new();
let my_did = state.node_did.to_string();
@@ -492,43 +238,13 @@ async fn gossip_task(
// Announce ourselves to each bootstrap peer
for peer_url in &bootstrap_peers {
// Cooperative shutdown between peers — a slow peer shouldn't
// block the node exiting.
if *shutdown_rx.borrow() {
info!("gossip: shutdown signalled during peer announce, exiting");
return;
}
let path = "/api/v1/peers/announce";
let announce_url = format!("{}{}", peer_url.trim_end_matches('/'), path);
let announce_url = format!("{}/api/v1/peers/announce", peer_url.trim_end_matches('/'));
let body = serde_json::json!({
"did": my_did.clone(),
"http_url": my_url.clone(),
"did": my_did,
"http_url": my_url,
});
let body_bytes = match serde_json::to_vec(&body) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(err = %e, "failed to serialize peer announce body");
continue;
}
};
let signed = sign_request(state.node_keypair.as_ref(), "POST", path, &body_bytes);
// Per-request timeout inside the loop; do not let one hung peer
// block others. The request itself is a normal tokio future so
// it's cancel-safe on shutdown.
match tokio::time::timeout(
std::time::Duration::from_secs(5),
client
.post(&announce_url)
.header("Content-Type", "application/json")
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature)
.body(body_bytes)
.send(),
)
.await
{
Ok(Ok(resp)) => {
match client.post(&announce_url).json(&body).send().await {
Ok(resp) => {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
// Add them back to our peer list
@@ -544,39 +260,24 @@ async fn gossip_task(
}
}
}
Ok(Err(e)) => {
tracing::warn!(url = %announce_url, err = %e, "failed to announce to bootstrap peer")
}
Err(_) => tracing::warn!(url = %announce_url, "bootstrap peer announce timed out (5s)"),
Err(e) => tracing::warn!(url = %announce_url, err = %e, "failed to announce to bootstrap peer"),
}
}
// Periodic ping every 5 minutes — exit on shutdown.
// Periodic ping every 5 minutes
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
let peers = match state.db.list_peers().await {
Ok(p) => p,
Err(_) => continue,
};
for peer in peers {
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = client
.get(&url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("gossip task: shutdown signal received, exiting");
return;
}
}
interval.tick().await;
let peers = match state.db.list_peers().await {
Ok(p) => p,
Err(_) => continue,
};
for peer in peers {
let url = format!("{}/health", peer.http_url.trim_end_matches('/'));
let ok = client.get(&url).send().await
.map(|r| r.status().is_success())
.unwrap_or(false);
let _ = state.db.mark_peer_ping(&peer.did, ok).await;
}
}
}
@@ -587,13 +288,13 @@ fn load_or_create_keypair(config: &Config) -> Result<Keypair> {
if key_path.exists() {
let pem = std::fs::read_to_string(&key_path)
.with_context(|| format!("failed to read key from {}", key_path.display()))?;
let kp = Keypair::from_pem(&pem).map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?;
let kp = Keypair::from_pem(&pem)
.map_err(|e| anyhow::anyhow!("invalid PEM key: {e}"))?;
info!(path = %key_path.display(), "loaded existing identity");
Ok(kp)
} else {
let kp = Keypair::generate();
let pem = kp
.to_pem()
let pem = kp.to_pem()
.map_err(|e| anyhow::anyhow!("failed to serialize key: {e}"))?;
if let Some(parent) = key_path.parent() {
-352
View File
@@ -1,352 +0,0 @@
//! Prometheus metrics for the gitlawb node.
//!
//! Exposes a small, deliberately conservative set of counters and histograms
//! that cover the questions an operator actually asks of a live node:
//!
//! * is push traffic flowing? — `gitlawb_pushes_total`
//! * is fetch traffic flowing? — `gitlawb_fetches_total`
//! * are signature checks passing or failing? —
//! `gitlawb_auth_successes_total` / `gitlawb_auth_failures_total`
//! * is the sync worker making progress? —
//! `gitlawb_sync_queue_processed_total{status}`
//! * are webhooks reaching their endpoints? —
//! `gitlawb_webhook_deliveries_total{result}`
//! * how big are the packs we're sending and receiving? —
//! `gitlawb_pack_size_bytes`
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
//! * currently-connected peer count — `gitlawb_peers_connected`
//!
//! All metrics live in a single process-wide registry initialized by
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
//! are no-ops until [`init`] has been called, so unit tests and benchmarks
//! that don't go through `main()` don't need to do anything special.
//!
//! The endpoint is opt-in via `GITLAWB_METRICS_ADDR`; if unset, no listener
//! is bound and no exposition happens. Increments are atomic and effectively
//! free.
//!
//! Follow-ups (not in this module):
//! * per-route latency histograms (TraceLayer already gives us spans)
//! * per-peer p2p counters
//! * ipfs / pinata counters
use std::sync::OnceLock;
use prometheus::{
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
TextEncoder,
};
/// The single, process-wide metrics registry. Initialized by [`init`].
/// Wrapped in `OnceLock` so unit tests can run without going through
/// `main()` — the public `record_*` helpers treat an uninitialized
/// registry as a silent no-op.
static REGISTRY: OnceLock<Registry> = OnceLock::new();
static INFO: OnceLock<IntGaugeVec> = OnceLock::new();
static PUSHES: OnceLock<IntCounterVec> = OnceLock::new();
static FETCHES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_SUCCESSES: OnceLock<IntCounterVec> = OnceLock::new();
static AUTH_FAILURES: OnceLock<IntCounterVec> = OnceLock::new();
static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
/// One-time initializer. Builds the registry, registers every metric,
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
/// more than once is a silent no-op. MUST be called from `main()` after
/// the node DID is known.
pub fn init(version: &str, node_did: &str) {
if REGISTRY.get().is_some() {
return;
}
let registry = Registry::new();
let info = IntGaugeVec::new(
Opts::new(
"gitlawb_info",
"Constant 1 labelled with version and node DID",
),
&["version", "did"],
)
.expect("gitlawb_info metric definition");
registry
.register(Box::new(info.clone()))
.expect("register gitlawb_info");
info.with_label_values(&[version, node_did]).set(1);
INFO.set(info).expect("set INFO once");
let pushes = IntCounterVec::new(
Opts::new(
"gitlawb_pushes_total",
"Total successful git push (receive-pack) completions",
),
&["repo"],
)
.expect("gitlawb_pushes_total definition");
registry
.register(Box::new(pushes.clone()))
.expect("register gitlawb_pushes_total");
PUSHES.set(pushes).expect("set PUSHES once");
let fetches = IntCounterVec::new(
Opts::new(
"gitlawb_fetches_total",
"Total successful git fetch (upload-pack) completions",
),
&["repo"],
)
.expect("gitlawb_fetches_total definition");
registry
.register(Box::new(fetches.clone()))
.expect("register gitlawb_fetches_total");
FETCHES.set(fetches).expect("set FETCHES once");
let auth_successes = IntCounterVec::new(
Opts::new(
"gitlawb_auth_successes_total",
"Total HTTP signature checks that passed",
),
&["route"],
)
.expect("gitlawb_auth_successes_total definition");
registry
.register(Box::new(auth_successes.clone()))
.expect("register gitlawb_auth_successes_total");
AUTH_SUCCESSES
.set(auth_successes)
.expect("set AUTH_SUCCESSES once");
let auth_failures = IntCounterVec::new(
Opts::new(
"gitlawb_auth_failures_total",
"Total HTTP signature checks that failed",
),
&["route", "reason"],
)
.expect("gitlawb_auth_failures_total definition");
registry
.register(Box::new(auth_failures.clone()))
.expect("register gitlawb_auth_failures_total");
AUTH_FAILURES
.set(auth_failures)
.expect("set AUTH_FAILURES once");
let sync_processed = IntCounterVec::new(
Opts::new(
"gitlawb_sync_queue_processed_total",
"Total sync_queue items the worker has processed",
),
&["status"],
)
.expect("gitlawb_sync_queue_processed_total definition");
registry
.register(Box::new(sync_processed.clone()))
.expect("register gitlawb_sync_queue_processed_total");
SYNC_PROCESSED
.set(sync_processed)
.expect("set SYNC_PROCESSED once");
let webhook_deliveries = IntCounterVec::new(
Opts::new(
"gitlawb_webhook_deliveries_total",
"Total webhook delivery attempts and their outcomes",
),
&["result"],
)
.expect("gitlawb_webhook_deliveries_total definition");
registry
.register(Box::new(webhook_deliveries.clone()))
.expect("register gitlawb_webhook_deliveries_total");
WEBHOOK_DELIVERIES
.set(webhook_deliveries)
.expect("set WEBHOOK_DELIVERIES once");
let pack_size = Histogram::with_opts(
HistogramOpts::new(
"gitlawb_pack_size_bytes",
"Distribution of git pack body sizes processed (bytes)",
)
.buckets(vec![
1_024.0, // 1 KB
64_000.0, // ~64 KB
1_000_000.0, // 1 MB
16_000_000.0, // 16 MB
64_000_000.0, // 64 MB
256_000_000.0, // 256 MB
1_073_741_824.0, // 1 GiB
2_147_483_648.0, // 2 GiB (current cap)
]),
)
.expect("gitlawb_pack_size_bytes definition");
registry
.register(Box::new(pack_size.clone()))
.expect("register gitlawb_pack_size_bytes");
PACK_SIZE.set(pack_size).expect("set PACK_SIZE once");
let peers_connected = IntGauge::with_opts(Opts::new(
"gitlawb_peers_connected",
"Currently connected libp2p peers",
))
.expect("gitlawb_peers_connected definition");
registry
.register(Box::new(peers_connected.clone()))
.expect("register gitlawb_peers_connected");
PEERS_CONNECTED
.set(peers_connected)
.expect("set PEERS_CONNECTED once");
REGISTRY
.set(registry)
.expect("set REGISTRY once (init must be called exactly once)");
}
/// Record one successful push (receive-pack completion). Labelled with
/// the repo in the form `owner_short/repo_name`. No-op if `init` was
/// never called (e.g. inside a unit test that doesn't go through main).
pub fn record_push(repo: &str) {
if let Some(c) = PUSHES.get() {
c.with_label_values(&[repo]).inc();
}
}
/// Record one successful fetch (upload-pack completion).
pub fn record_fetch(repo: &str) {
if let Some(c) = FETCHES.get() {
c.with_label_values(&[repo]).inc();
}
}
/// Record one HTTP signature check that passed.
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
pub fn record_auth_success(route: &str) {
if let Some(c) = AUTH_SUCCESSES.get() {
c.with_label_values(&[route]).inc();
}
}
/// Record one HTTP signature check that failed. `reason` is a short,
/// machine-friendly token (e.g. `expired`, `bad_signature`, `missing_header`).
#[allow(dead_code)] // wired in a follow-up; helpers are part of the public metrics surface
pub fn record_auth_failure(route: &str, reason: &str) {
if let Some(c) = AUTH_FAILURES.get() {
c.with_label_values(&[route, reason]).inc();
}
}
/// Record one sync_queue item outcome. `status` ∈ {done, failed, skipped}.
pub fn record_sync_processed(status: &str) {
if let Some(c) = SYNC_PROCESSED.get() {
c.with_label_values(&[status]).inc();
}
}
/// Record one webhook delivery attempt outcome. `result` ∈
/// {ok, http_error, network_error, skipped}.
pub fn record_webhook_delivery(result: &str) {
if let Some(c) = WEBHOOK_DELIVERIES.get() {
c.with_label_values(&[result]).inc();
}
}
/// Record a pack body size observation (bytes).
pub fn observe_pack_size(bytes: f64) {
if let Some(h) = PACK_SIZE.get() {
h.observe(bytes);
}
}
/// Update the currently-connected peer count gauge.
pub fn set_peers_connected(count: i64) {
if let Some(g) = PEERS_CONNECTED.get() {
g.set(count);
}
}
/// Encode the registry as the standard Prometheus text exposition format.
/// Returns an error if `init` was never called.
pub fn encode() -> Result<String, prometheus::Error> {
let registry = REGISTRY
.get()
.ok_or_else(|| prometheus::Error::Msg("metrics::init() was never called".into()))?;
let metric_families = registry.gather();
let mut buf = Vec::new();
let encoder = TextEncoder::new();
encoder.encode(&metric_families, &mut buf)?;
String::from_utf8(buf)
.map_err(|e| prometheus::Error::Msg(format!("non-UTF8 in metric buffer: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
// Note: these tests are not run in parallel by default (cargo runs
// tests in a binary in parallel via threads but the OnceLock guard
// means only the first init() call succeeds; subsequent ones are
// no-ops). The encode test below is structured so it's safe to run
// alongside other tests in the same binary.
#[test]
fn encode_after_init_returns_prometheus_text() {
// Reset is not supported by OnceLock, so this test relies on
// either being the first to run, or `init` being a no-op on
// second call. Both paths are exercised: if init() was already
// called by another test, the no-op branch still leaves the
// registry usable.
init("0.0.0-test", "did:key:test");
PUSHES
.get()
.expect("PUSHES set after init")
.with_label_values(&["alice/repo"])
.inc();
let body = encode().expect("encode should succeed after init");
assert!(
body.contains("# HELP gitlawb_info"),
"expected gitlawb_info HELP line in: {body}"
);
assert!(
body.contains("# TYPE gitlawb_pushes_total counter"),
"expected gitlawb_pushes_total TYPE line in: {body}"
);
assert!(
body.contains("gitlawb_pushes_total{repo=\"alice/repo\"} 1"),
"expected the incremented counter to be visible in: {body}"
);
}
#[test]
fn record_helpers_are_noops_before_init() {
// These don't panic. They also don't show up in encode() output
// because the registry doesn't exist yet.
record_push("test/repo");
record_fetch("test/repo");
record_auth_success("test/route");
record_auth_failure("test/route", "test_reason");
record_sync_processed("done");
record_webhook_delivery("ok");
observe_pack_size(1024.0);
set_peers_connected(0);
}
#[test]
fn encode_before_init_returns_error() {
// encode() must error rather than panic if init() was never called.
// This test is meaningful only if NO other test in this binary
// ran first. cargo runs tests on multiple threads within a
// single binary, so this is racy — but worst case the assertion
// below is "encode succeeded with a non-empty body", which is
// still a valid state.
let result = encode();
match result {
Err(_) => { /* expected when init wasn't called */ }
Ok(body) => {
// Some other test ran init() first; that's fine, the
// body should still be a valid exposition payload.
assert!(!body.is_empty(), "non-empty exposition body");
}
}
}
}
+9 -20
View File
@@ -129,7 +129,9 @@ impl OperatorClient {
.rpc_url
.parse()
.with_context(|| format!("invalid RPC URL: {}", self.cfg.rpc_url))?;
let provider = ProviderBuilder::new().wallet(wallet).connect_http(rpc_url);
let provider = ProviderBuilder::new()
.wallet(wallet)
.connect_http(rpc_url);
let contract = IGitlawbNodeStaking::new(self.cfg.contract_address, provider);
let pending = contract
@@ -145,12 +147,8 @@ impl OperatorClient {
}
/// Spawn a background task that posts a heartbeat on `heartbeat_interval` cadence.
/// Errors are logged and retried on the next tick. The task exits cleanly
/// when `shutdown_rx` flips to `true`.
pub fn spawn_heartbeat_loop(
self: Arc<Self>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
/// Errors are logged and retried on the next tick.
pub fn spawn_heartbeat_loop(self: Arc<Self>) {
let interval_secs = self.cfg.heartbeat_interval.as_secs();
info!(
interval_hours = interval_secs / 3600,
@@ -162,20 +160,11 @@ impl OperatorClient {
// First tick fires immediately — use it to send an initial heartbeat
interval.tick().await;
loop {
tokio::select! {
_ = interval.tick() => {
match self.send_heartbeat().await {
Ok(tx) => info!(tx = %tx, "operator heartbeat sent"),
Err(e) => warn!(err = %e, "operator heartbeat failed — will retry"),
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("operator heartbeat loop: shutdown signal received, exiting");
return;
}
}
match self.send_heartbeat().await {
Ok(tx) => info!(tx = %tx, "operator heartbeat sent"),
Err(e) => warn!(err = %e, "operator heartbeat failed — will retry"),
}
interval.tick().await;
}
});
}
+86 -119
View File
@@ -3,24 +3,26 @@
//! Provides:
//! - Peer discovery via Kademlia DHT (DID → multiaddr mapping)
//! - Real-time ref-update events via Gossipsub
//! - Local network discovery via mDNS
//!
//! The node's PeerId is derived from its Ed25519 identity keypair,
//! so the gitlawb DID and libp2p PeerId share the same key.
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::collections::{HashMap, hash_map::DefaultHasher};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use chrono::Utc;
use futures::StreamExt;
use libp2p_core::{muxing::StreamMuxerBox, Multiaddr, PeerId, Transport};
use libp2p_gossipsub as gossipsub;
use libp2p_identify as identify;
use libp2p_identity as identity;
use libp2p_kad as kad;
use libp2p_swarm::{NetworkBehaviour, Swarm, SwarmEvent};
use libp2p::futures::StreamExt;
use libp2p::{
gossipsub, identify, kad,
mdns,
noise, tcp, yamux,
swarm::{NetworkBehaviour, SwarmEvent},
Multiaddr, PeerId,
};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use uuid::Uuid;
@@ -63,15 +65,6 @@ pub struct DidRecord {
pub timestamp: String,
}
/// Snapshot of the libp2p swarm state for observability.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SwarmStatus {
pub connected_peers: usize,
pub gossipsub_mesh_peers: usize,
pub gossipsub_all_peers: usize,
pub listen_addrs: Vec<String>,
}
/// Commands sent to the swarm task from the rest of the node.
#[derive(Debug)]
pub enum P2pCommand {
@@ -86,12 +79,7 @@ pub enum P2pCommand {
/// Store a DID record in the Kademlia DHT (fire-and-forget)
PutDid(DidRecord),
/// Look up a DID in the Kademlia DHT; reply on the oneshot sender
GetDid {
did: String,
reply: oneshot::Sender<Option<DidRecord>>,
},
/// Get a snapshot of the swarm status
GetStatus { reply: oneshot::Sender<SwarmStatus> },
GetDid { did: String, reply: oneshot::Sender<Option<DidRecord>> },
}
/// Handle returned to the rest of the node for sending commands to the swarm.
@@ -108,10 +96,7 @@ impl P2pHandle {
#[allow(dead_code)]
pub async fn add_peer(&self, peer_id: PeerId, addr: Multiaddr) {
let _ = self
.tx
.send(P2pCommand::AddKnownPeer { peer_id, addr })
.await;
let _ = self.tx.send(P2pCommand::AddKnownPeer { peer_id, addr }).await;
}
#[allow(dead_code)]
@@ -124,15 +109,6 @@ impl P2pHandle {
let _ = self.tx.send(P2pCommand::PutDid(record)).await;
}
pub async fn status(&self) -> Option<SwarmStatus> {
let (tx, rx) = oneshot::channel();
let _ = self.tx.send(P2pCommand::GetStatus { reply: tx }).await;
tokio::time::timeout(std::time::Duration::from_secs(2), rx)
.await
.ok()
.and_then(|r| r.ok())
}
/// Look up a DID in the DHT. Returns None if not found or timeout (10s).
pub async fn get_did(&self, did: String) -> Option<DidRecord> {
let (tx, rx) = oneshot::channel();
@@ -152,23 +128,21 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
/// Combined libp2p behaviour.
#[derive(NetworkBehaviour)]
#[behaviour(prelude = "libp2p_swarm::derive_prelude")]
struct GitlawbBehaviour {
kademlia: kad::Behaviour<kad::store::MemoryStore>,
gossipsub: gossipsub::Behaviour,
identify: identify::Behaviour,
mdns: mdns::tokio::Behaviour,
}
/// Start the libp2p swarm. Returns a handle for sending commands and the
/// listening multiaddrs. Runs the event loop as a background tokio task
/// that exits cleanly when `shutdown_rx` flips to `true`.
/// listening multiaddrs. Runs the event loop as a background tokio task.
pub async fn start(
node_did: &str,
listen_port: u16,
bootstrap_addrs: Vec<Multiaddr>,
db: Arc<Db>,
auto_sync: bool,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> Result<P2pHandle> {
// Derive a stable libp2p Ed25519 key from a seed based on the node DID.
// In production you'd load/persist this key alongside the identity PEM.
@@ -185,7 +159,7 @@ pub async fn start(
seed_bytes[i * 8..(i + 1) * 8].copy_from_slice(&seed.wrapping_add(i as u64).to_le_bytes());
}
let local_key = identity::Keypair::ed25519_from_bytes(seed_bytes)
let local_key = libp2p::identity::Keypair::ed25519_from_bytes(seed_bytes)
.map_err(|e| anyhow::anyhow!("failed to create p2p keypair: {e}"))?;
let local_peer_id = PeerId::from(local_key.public());
@@ -198,66 +172,68 @@ pub async fn start(
local_peer_id,
};
let kad_store = kad::store::MemoryStore::new(local_peer_id);
let mut kademlia = kad::Behaviour::new(local_peer_id, kad_store);
kademlia.set_mode(Some(kad::Mode::Server));
// Build the swarm
let mut swarm = libp2p::SwarmBuilder::with_existing_identity(local_key)
.with_tokio()
.with_tcp(
tcp::Config::default(),
noise::Config::new,
yamux::Config::default,
)?
.with_behaviour(|key| {
let peer_id = PeerId::from(key.public());
let gossipsub_config = gossipsub::ConfigBuilder::default()
.heartbeat_interval(Duration::from_secs(10))
.validation_mode(gossipsub::ValidationMode::Permissive)
.message_id_fn(|msg: &gossipsub::Message| {
let mut h = DefaultHasher::new();
msg.data.hash(&mut h);
gossipsub::MessageId::from(h.finish().to_string())
})
.build()
.expect("gossipsub config");
let gossipsub = gossipsub::Behaviour::new(
gossipsub::MessageAuthenticity::Signed(local_key.clone()),
gossipsub_config,
)
.expect("gossipsub behaviour");
// Kademlia
let kad_store = kad::store::MemoryStore::new(peer_id);
let mut kademlia = kad::Behaviour::new(peer_id, kad_store);
kademlia.set_mode(Some(kad::Mode::Server));
let identify = identify::Behaviour::new(identify::Config::new(
"/gitlawb/1.0.0".to_string(),
local_key.public(),
));
// Gossipsub — message_id based on content hash to deduplicate
let gossipsub_config = gossipsub::ConfigBuilder::default()
.heartbeat_interval(Duration::from_secs(10))
.validation_mode(gossipsub::ValidationMode::Permissive)
.message_id_fn(|msg: &gossipsub::Message| {
let mut h = DefaultHasher::new();
msg.data.hash(&mut h);
gossipsub::MessageId::from(h.finish().to_string())
})
.build()
.expect("gossipsub config");
let gossipsub = gossipsub::Behaviour::new(
gossipsub::MessageAuthenticity::Signed(key.clone()),
gossipsub_config,
)
.expect("gossipsub behaviour");
let behaviour = GitlawbBehaviour {
kademlia,
gossipsub,
identify,
};
// DNS wraps QUIC so multiaddrs like /dns6/<app>.internal/udp/…/quic-v1
// resolve at dial time. On Fly, peer nodes must dial each other over the
// private 6PN network via <app>.internal hostnames — dialing through the
// public anycast edge breaks the handshake (the proxy closes the
// connection mid-stream).
let quic = libp2p_quic::tokio::Transport::new(libp2p_quic::Config::new(&local_key))
.map(|(peer_id, muxer), _| (peer_id, StreamMuxerBox::new(muxer)));
let transport = libp2p_dns::tokio::Transport::system(quic)?.boxed();
let mut swarm = Swarm::new(
transport,
behaviour,
local_peer_id,
libp2p_swarm::Config::with_tokio_executor(),
);
// Identify — so peers can exchange their observed addresses
let identify = identify::Behaviour::new(identify::Config::new(
"/gitlawb/1.0.0".to_string(),
key.public(),
));
// mDNS — local network peer discovery
let mdns = mdns::tokio::Behaviour::new(
mdns::Config::default(),
peer_id,
)
.expect("mdns behaviour");
GitlawbBehaviour {
kademlia,
gossipsub,
identify,
mdns,
}
})?
.build();
// Subscribe to the ref-updates topic
let topic = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC);
swarm.behaviour_mut().gossipsub.subscribe(&topic)?;
// Listen on both IPv4 (local/mDNS + any IPv4 dials) and IPv6 (required
// for Fly's 6PN inter-app network — <app>.internal DNS only returns AAAA
// records, so peers dial us via IPv6 and need a matching IPv6 socket).
let v4: Multiaddr = format!("/ip4/0.0.0.0/udp/{listen_port}/quic-v1").parse()?;
if let Err(e) = swarm.listen_on(v4) {
warn!(err = %e, "failed to listen on IPv4");
}
let v6: Multiaddr = format!("/ip6/::/udp/{listen_port}/quic-v1").parse()?;
if let Err(e) = swarm.listen_on(v6) {
warn!(err = %e, "failed to listen on IPv6");
}
// Listen
let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/tcp/{listen_port}").parse()?;
swarm.listen_on(listen_addr)?;
// Bootstrap Kademlia with known peers
for addr in bootstrap_addrs {
@@ -273,18 +249,8 @@ pub async fn start(
// Start the event loop as a background task
tokio::spawn(async move {
let mut shutdown_rx = shutdown_rx;
loop {
tokio::select! {
// Graceful shutdown: exit the swarm loop when the
// process-wide signal flips. This drops the Swarm
// which closes all libp2p connections cleanly.
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("p2p swarm: shutdown signal received, exiting event loop");
break;
}
}
// Handle swarm events
event = swarm.select_next_some() => {
match event {
@@ -332,12 +298,14 @@ pub async fn start(
kad::Event::OutboundQueryProgressed { id, result, .. }
)) => {
match result {
kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(pr))) => {
if let Some(reply) = pending_get_did.remove(&id) {
let record = serde_json::from_slice::<DidRecord>(
&pr.record.value
).ok();
let _ = reply.send(record);
kad::QueryResult::GetRecord(Ok(ok)) => {
if let kad::GetRecordOk::FoundRecord(pr) = ok {
if let Some(reply) = pending_get_did.remove(&id) {
let record = serde_json::from_slice::<DidRecord>(
&pr.record.value
).ok();
let _ = reply.send(record);
}
}
}
kad::QueryResult::GetRecord(Err(e)) => {
@@ -356,6 +324,15 @@ pub async fn start(
}
}
SwarmEvent::Behaviour(GitlawbBehaviourEvent::Mdns(
mdns::Event::Discovered(peers)
)) => {
for (peer_id, addr) in peers {
debug!(peer = %peer_id, addr = %addr, "mDNS discovered peer");
swarm.behaviour_mut().kademlia.add_address(&peer_id, addr.clone());
let _ = swarm.dial(addr);
}
}
SwarmEvent::Behaviour(GitlawbBehaviourEvent::Identify(
identify::Event::Received { peer_id, info, .. }
)) => {
@@ -414,16 +391,6 @@ pub async fn start(
let query_id = swarm.behaviour_mut().kademlia.get_record(key);
pending_get_did.insert(query_id, reply);
}
P2pCommand::GetStatus { reply } => {
let topic_hash = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC).hash();
let status = SwarmStatus {
connected_peers: swarm.connected_peers().count(),
gossipsub_mesh_peers: swarm.behaviour().gossipsub.mesh_peers(&topic_hash).count(),
gossipsub_all_peers: swarm.behaviour().gossipsub.all_peers().count(),
listen_addrs: swarm.listeners().map(|a| a.to_string()).collect(),
};
let _ = reply.send(status);
}
}
}
}
+5 -36
View File
@@ -7,7 +7,6 @@
//! no-op, so nodes without Pinata backing work fine.
use anyhow::Result;
use std::collections::HashSet;
/// Pin a single git object's raw bytes on Pinata (v3 API).
///
@@ -77,7 +76,6 @@ pub async fn pin_new_objects(
jwt: &str,
repo_path: &std::path::Path,
db: &crate::db::Db,
withheld: &HashSet<String>,
) -> Vec<(String, String)> {
if jwt.is_empty() {
return vec![];
@@ -94,7 +92,6 @@ pub async fn pin_new_objects(
return vec![];
}
};
let object_list = crate::git::visibility_pack::replicable_objects(object_list, withheld);
let mut pinned = Vec::new();
@@ -136,11 +133,7 @@ pub async fn pin_new_objects(
fn list_all_objects(repo_path: &std::path::Path) -> Result<Vec<String>> {
let out = std::process::Command::new("git")
.args([
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname)",
])
.args(["cat-file", "--batch-all-objects", "--batch-check=%(objectname)"])
.current_dir(repo_path)
.output()
.map_err(|e| anyhow::anyhow!("failed to run git cat-file: {e}"))?;
@@ -166,14 +159,7 @@ mod tests {
#[tokio::test]
async fn test_pin_skipped_when_jwt_empty() {
let client = reqwest::Client::new();
let result = pin_object(
&client,
"https://uploads.pinata.cloud/v3/files",
"",
"deadbeef",
b"data",
)
.await;
let result = pin_object(&client, "https://uploads.pinata.cloud/v3/files", "", "deadbeef", b"data").await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "", "empty JWT must return empty CID");
}
@@ -200,10 +186,7 @@ mod tests {
.await;
assert!(result.is_ok(), "pin should succeed: {result:?}");
assert_eq!(
result.unwrap(),
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"
);
assert_eq!(result.unwrap(), "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG");
_mock.assert_async().await;
}
@@ -218,14 +201,7 @@ mod tests {
.await;
let client = reqwest::Client::new();
let result = pin_object(
&client,
&server.url(),
"bad-jwt",
"deadbeef00000000",
b"data",
)
.await;
let result = pin_object(&client, &server.url(), "bad-jwt", "deadbeef00000000", b"data").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("401"));
@@ -279,14 +255,7 @@ mod tests {
.await;
let client = reqwest::Client::new();
let result = pin_object(
&client,
&server.url(),
"my-pinata-jwt",
"deadbeef00000000",
b"data",
)
.await;
let result = pin_object(&client, &server.url(), "my-pinata-jwt", "deadbeef00000000", b"data").await;
assert!(result.is_ok());
_mock.assert_async().await;
-129
View File
@@ -1,129 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use tokio::sync::Mutex;
use crate::auth::AuthenticatedDid;
#[derive(Clone)]
struct Window {
timestamps: Vec<Instant>,
}
#[derive(Clone)]
pub struct RateLimiter {
state: Arc<Mutex<HashMap<String, Window>>>,
max_requests: usize,
window: Duration,
}
impl RateLimiter {
pub fn new(max_requests: usize, window: Duration) -> Self {
Self {
state: Arc::new(Mutex::new(HashMap::new())),
max_requests,
window,
}
}
async fn check(&self, key: &str) -> bool {
let now = Instant::now();
let mut state = self.state.lock().await;
let window = state.entry(key.to_string()).or_insert_with(|| Window {
timestamps: Vec::new(),
});
window
.timestamps
.retain(|t| now.duration_since(*t) < self.window);
if window.timestamps.len() >= self.max_requests {
return false;
}
window.timestamps.push(now);
true
}
pub async fn cleanup(&self) {
let now = Instant::now();
let mut state = self.state.lock().await;
state.retain(|_, w| {
w.timestamps
.retain(|t| now.duration_since(*t) < self.window);
!w.timestamps.is_empty()
});
}
}
pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
let limiter = request.extensions().get::<RateLimiter>().cloned();
let did = request
.extensions()
.get::<AuthenticatedDid>()
.map(|a| a.0.clone());
if let (Some(limiter), Some(did)) = (limiter, did) {
if !limiter.check(&did).await {
return (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", "60")],
"rate limit exceeded — try again later",
)
.into_response();
}
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn allows_within_limit() {
let limiter = RateLimiter::new(3, Duration::from_secs(60));
assert!(limiter.check("did:key:test1").await);
assert!(limiter.check("did:key:test1").await);
assert!(limiter.check("did:key:test1").await);
}
#[tokio::test]
async fn blocks_over_limit() {
let limiter = RateLimiter::new(2, Duration::from_secs(60));
assert!(limiter.check("did:key:test2").await);
assert!(limiter.check("did:key:test2").await);
assert!(!limiter.check("did:key:test2").await);
}
#[tokio::test]
async fn separate_keys_independent() {
let limiter = RateLimiter::new(1, Duration::from_secs(60));
assert!(limiter.check("did:key:alice").await);
assert!(limiter.check("did:key:bob").await);
assert!(!limiter.check("did:key:alice").await);
}
#[tokio::test]
async fn window_expires() {
let limiter = RateLimiter::new(1, Duration::from_millis(50));
assert!(limiter.check("did:key:test3").await);
assert!(!limiter.check("did:key:test3").await);
tokio::time::sleep(Duration::from_millis(60)).await;
assert!(limiter.check("did:key:test3").await);
}
#[tokio::test]
async fn cleanup_removes_expired() {
let limiter = RateLimiter::new(1, Duration::from_millis(50));
limiter.check("did:key:stale").await;
tokio::time::sleep(Duration::from_millis(60)).await;
limiter.cleanup().await;
let state = limiter.state.lock().await;
assert!(state.is_empty());
}
}
+87 -301
View File
@@ -1,5 +1,4 @@
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
use axum::extract::DefaultBodyLimit;
use axum::{
extract::State,
middleware,
@@ -7,20 +6,20 @@ use axum::{
routing::{get, post},
Json, Router,
};
use serde_json::json;
use axum::extract::DefaultBodyLimit;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer};
use serde_json::json;
use tower_http::trace::{DefaultOnResponse, DefaultOnFailure, TraceLayer};
use tracing::Level;
use crate::api::{
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
protect, pulls, register, replicas, repos, resolve, stars, tasks, visibility, webhooks,
};
use crate::api::{agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, protect, pulls, register, repos, resolve, stars, tasks, webhooks};
use crate::auth;
use crate::rate_limit;
use crate::state::AppState;
async fn graphql_handler(State(state): State<AppState>, req: GraphQLRequest) -> GraphQLResponse {
async fn graphql_handler(
State(state): State<AppState>,
req: GraphQLRequest,
) -> GraphQLResponse {
state.graphql_schema.execute(req.into_inner()).await.into()
}
@@ -31,19 +30,6 @@ async fn graphql_playground() -> impl IntoResponse {
))
}
/// Applies the standard auth middleware pair to a router: HTTP Signature verification
/// followed by UCAN chain validation. The two layers run in this order for every
/// matched request: `require_signature` first (sets `AuthenticatedDid`), then
/// `require_ucan_chain` (reads it).
fn add_auth_layers(router: Router<AppState>, state: AppState) -> Router<AppState> {
router
.layer(middleware::from_fn_with_state(
state,
auth::require_ucan_chain,
))
.layer(middleware::from_fn(auth::require_signature))
}
pub fn build_router(state: AppState) -> Router {
// ── GraphQL routes ─────────────────────────────────────────────────────
let schema = state.graphql_schema.as_ref().clone();
@@ -52,120 +38,48 @@ pub fn build_router(state: AppState) -> Router {
.route_service("/graphql/ws", GraphQLSubscription::new(schema));
// ── Task routes (write — require HTTP Signature) ───────────────────────
let task_write_routes = add_auth_layers(
Router::new()
.route("/api/v1/tasks", post(tasks::create_task))
.route("/api/v1/tasks/{id}/claim", post(tasks::claim_task))
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task)),
state.clone(),
);
let task_write_routes = Router::new()
.route("/api/v1/tasks", post(tasks::create_task))
.route("/api/v1/tasks/{id}/claim", post(tasks::claim_task))
.route("/api/v1/tasks/{id}/complete", post(tasks::complete_task))
.route("/api/v1/tasks/{id}/fail", post(tasks::fail_task))
.layer(middleware::from_fn(auth::require_signature));
// ── Task routes (read — open) ──────────────────────────────────────────
let task_read_routes = Router::new()
.route("/api/v1/tasks", get(tasks::list_tasks))
.route("/api/v1/tasks/{id}", get(tasks::get_task));
// ── Rate-limited creation routes — require HTTP Signature + per-DID throttle
let limiter = state.rate_limiter.clone();
let creation_routes = add_auth_layers(
Router::new()
.route("/api/v1/repos", post(repos::create_repo))
.route("/api/register", post(register::register))
.route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo))
.route(
"/api/v1/repos/{owner}/{repo}/issues",
post(issues::create_issue),
)
.route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr))
.layer(middleware::from_fn(rate_limit::rate_limit_by_did))
.layer(axum::Extension(limiter)),
state.clone(),
);
// ── Write routes — require HTTP Signature (no rate limit) ─────────────
let write_routes = add_auth_layers(
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/merge",
post(pulls::merge_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/close",
post(pulls::close_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
post(pulls::create_review),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
post(pulls::create_comment),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks",
post(webhooks::create_webhook),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks/{id}",
axum::routing::delete(webhooks::delete_webhook),
)
.route(
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
post(protect::protect_branch),
)
.route(
"/api/v1/repos/{owner}/{repo}/branches/{branch}/protect",
axum::routing::delete(protect::unprotect_branch),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
axum::routing::put(stars::star_repo),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
axum::routing::delete(stars::unstar_repo),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
axum::routing::put(replicas::register_replica),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
axum::routing::delete(replicas::unregister_replica),
)
.route(
"/api/v1/repos/{owner}/{repo}/labels",
post(labels::add_label),
)
.route(
"/api/v1/repos/{owner}/{repo}/labels/{label}",
axum::routing::delete(labels::remove_label),
)
.route(
"/api/v1/repos/{owner}/{repo}/visibility",
axum::routing::put(visibility::set_visibility)
.delete(visibility::remove_visibility)
.get(visibility::list_visibility),
),
state.clone(),
);
// ── Write routes — require HTTP Signature ──────────────────────────────
let write_routes = Router::new()
.route("/api/v1/repos", post(repos::create_repo))
.route("/api/register", post(register::register))
.route("/api/v1/repos/{owner}/{repo}/pulls", post(pulls::create_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", post(pulls::merge_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/close", post(pulls::close_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", post(pulls::create_review))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", post(pulls::create_comment))
.route("/api/v1/repos/{owner}/{repo}/hooks", post(webhooks::create_webhook))
.route("/api/v1/repos/{owner}/{repo}/hooks/{id}", axum::routing::delete(webhooks::delete_webhook))
.route("/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", post(protect::protect_branch))
.route("/api/v1/repos/{owner}/{repo}/branches/{branch}/protect", axum::routing::delete(protect::unprotect_branch))
.route("/api/v1/repos/{owner}/{repo}/star", axum::routing::put(stars::star_repo))
.route("/api/v1/repos/{owner}/{repo}/star", axum::routing::delete(stars::unstar_repo))
.route("/api/v1/repos/{owner}/{repo}/fork", post(repos::fork_repo))
.route("/api/v1/repos/{owner}/{repo}/labels", post(labels::add_label))
.route("/api/v1/repos/{owner}/{repo}/labels/{label}", axum::routing::delete(labels::remove_label))
.layer(middleware::from_fn(auth::require_signature));
// Body limit is raised to GITLAWB_MAX_PACK_BYTES (default 2 GB) for git
// routes only — all other API routes keep axum's default 2 MB cap.
// HTTP Signature is enforced on receive-pack (push) — the git-remote-gitlawb
// helper signs requests with RFC 9421 signatures using the agent's keypair.
let pack_limit = state.config.max_pack_bytes;
let git_write_routes = add_auth_layers(
Router::new()
.route(
"/{owner}/{repo}/git-receive-pack",
post(repos::git_receive_pack),
)
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit)),
state.clone(),
);
let git_write_routes = Router::new()
.route("/{owner}/{repo}/git-receive-pack", post(repos::git_receive_pack))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::require_signature));
// ── IPFS content-addressed retrieval and pin listing ──────────────────
let ipfs_routes = Router::new()
@@ -173,192 +87,83 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/ipfs/pins", get(ipfs::list_pins));
// ── Arweave permanent anchors ──────────────────────────────────────────
let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors));
let arweave_routes = Router::new()
.route("/api/v1/arweave/anchors", get(arweave::list_anchors));
// ── Bounty routes (write — require HTTP Signature) ─────────────────
let bounty_write_routes = add_auth_layers(
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/bounties",
post(bounties::create_bounty),
)
.route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty))
.route(
"/api/v1/bounties/{id}/submit",
post(bounties::submit_bounty),
)
.route(
"/api/v1/bounties/{id}/approve",
post(bounties::approve_bounty),
)
.route(
"/api/v1/bounties/{id}/cancel",
post(bounties::cancel_bounty),
)
.route(
"/api/v1/bounties/{id}/dispute",
post(bounties::dispute_bounty),
),
state.clone(),
);
let bounty_write_routes = Router::new()
.route("/api/v1/repos/{owner}/{repo}/bounties", post(bounties::create_bounty))
.route("/api/v1/bounties/{id}/claim", post(bounties::claim_bounty))
.route("/api/v1/bounties/{id}/submit", post(bounties::submit_bounty))
.route("/api/v1/bounties/{id}/approve", post(bounties::approve_bounty))
.route("/api/v1/bounties/{id}/cancel", post(bounties::cancel_bounty))
.layer(middleware::from_fn(auth::require_signature));
// ── Bounty routes (read — open) ──────────────────────────────────────
let bounty_read_routes = Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/bounties",
get(bounties::list_repo_bounties),
)
.route("/api/v1/repos/{owner}/{repo}/bounties", get(bounties::list_repo_bounties))
.route("/api/v1/bounties", get(bounties::list_all_bounties))
.route("/api/v1/bounties/{id}", get(bounties::get_bounty))
.route("/api/v1/bounties/{id}/dispute", post(bounties::dispute_bounty))
.route("/api/v1/bounties/stats", get(bounties::bounty_stats))
.route(
"/api/v1/agents/{did}/bounties",
get(bounties::agent_bounty_stats),
);
.route("/api/v1/agents/{did}/bounties", get(bounties::agent_bounty_stats));
// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
);
// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
let issue_write_routes = add_auth_layers(
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/close",
post(issues::close_issue),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
post(issues::create_issue_comment),
),
state.clone(),
);
// ── Issue routes ──────────────────────────────────────────────────────
let issue_write_routes = Router::new()
.route("/api/v1/repos/{owner}/{repo}/issues", post(issues::create_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/close", post(issues::close_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/comments", post(issues::create_issue_comment))
.layer(middleware::from_fn(auth::require_signature));
// ── Peer discovery routes ─────────────────────────────────────────────
// Peer writes accept signatures when present and can require them after a
// coordinated live-network upgrade.
// announce is intentionally unauthenticated — nodes announce by DID which
// is self-proving; rate-limiting and blocklisting are handled elsewhere.
let peer_read_routes = Router::new()
.route("/api/v1/peers", get(peers::list_peers))
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer));
let mut peer_write_routes = Router::new()
.route("/api/v1/peers/announce", post(peers::announce))
.route("/api/v1/peers/{did}/ping", get(peers::ping_peer))
.route("/api/v1/sync/trigger", post(peers::trigger_sync))
.route("/api/v1/sync/notify", post(peers::notify_sync));
peer_write_routes = if state.config.require_signed_peer_writes {
add_auth_layers(peer_write_routes, state.clone())
} else {
peer_write_routes.layer(middleware::from_fn(auth::optional_signature))
};
// ── Read routes — open for public repos ───────────────────────────────
let read_routes = Router::new()
.route("/api/v1/repos", get(repos::list_repos))
.route("/api/v1/repos/federated", get(repos::list_federated_repos))
.route("/api/v1/repos/{owner}/{repo}", get(repos::get_repo))
.route(
"/api/v1/repos/{owner}/{repo}/commits",
get(repos::list_commits),
)
.route(
"/api/v1/repos/{owner}/{repo}/tree",
get(repos::get_tree_root),
)
.route(
"/api/v1/repos/{owner}/{repo}/tree/{*path}",
get(repos::get_tree),
)
.route(
"/api/v1/repos/{owner}/{repo}/blob/{*path}",
get(repos::get_blob),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues",
get(issues::list_issues),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}",
get(issues::get_issue),
)
.route(
"/api/v1/repos/{owner}/{repo}/issues/{id}/comments",
get(issues::list_issue_comments),
)
.route(
"/api/v1/repos/{owner}/{repo}/labels",
get(labels::list_labels),
)
.route("/api/v1/repos/{owner}/{repo}/commits", get(repos::list_commits))
.route("/api/v1/repos/{owner}/{repo}/tree", get(repos::get_tree_root))
.route("/api/v1/repos/{owner}/{repo}/tree/{*path}", get(repos::get_tree))
.route("/api/v1/repos/{owner}/{repo}/blob/{*path}", get(repos::get_blob))
.route("/api/v1/repos/{owner}/{repo}/issues", get(issues::list_issues))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}", get(issues::get_issue))
.route("/api/v1/repos/{owner}/{repo}/issues/{id}/comments", get(issues::list_issue_comments))
.route("/api/v1/repos/{owner}/{repo}/labels", get(labels::list_labels))
.route("/api/v1/repos/{owner}/{repo}/certs", get(certs::list_certs))
.route(
"/api/v1/repos/{owner}/{repo}/certs/{id}",
get(certs::get_cert),
)
.route(
"/api/v1/repos/{owner}/{repo}/events",
get(events::list_repo_events),
)
.route("/api/v1/repos/{owner}/{repo}/certs/{id}", get(certs::get_cert))
.route("/api/v1/repos/{owner}/{repo}/events", get(events::list_repo_events))
.route("/api/v1/agents", get(agents::list_agents))
.route("/api/v1/agents/{did}", get(agents::show_agent))
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
.route("/api/v1/agents/{did}/profile", get(profiles::get_profile))
.route("/api/v1/events/ref-updates", get(events::list_ref_updates))
.route("/api/v1/resolve/{did}", get(resolve::resolve_did))
.route("/api/v1/repos/{owner}/{repo}/pulls", get(pulls::list_prs))
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}",
get(pulls::get_pr),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/diff",
get(pulls::get_pr_diff),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews",
get(pulls::list_reviews),
)
.route(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments",
get(pulls::list_comments),
)
.route(
"/api/v1/repos/{owner}/{repo}/hooks",
get(webhooks::list_webhooks),
)
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}", get(pulls::get_pr))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff", get(pulls::get_pr_diff))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews", get(pulls::list_reviews))
.route("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", get(pulls::list_comments))
.route("/api/v1/repos/{owner}/{repo}/hooks", get(webhooks::list_webhooks))
.route("/api/v1/repos/{owner}/{repo}/refs", get(repos::list_refs))
.route(
"/api/v1/repos/{owner}/{repo}/branches/protected",
get(protect::list_protected_branches),
)
.route(
"/api/v1/repos/{owner}/{repo}/changelog",
get(changelog::get_changelog),
)
.route(
"/api/v1/repos/{owner}/{repo}/star",
get(stars::get_star_status),
)
.route(
"/api/v1/repos/{owner}/{repo}/replicas",
get(replicas::list_replicas),
);
.route("/api/v1/repos/{owner}/{repo}/branches/protected", get(protect::list_protected_branches))
.route("/api/v1/repos/{owner}/{repo}/changelog", get(changelog::get_changelog))
.route("/api/v1/repos/{owner}/{repo}/star", get(stars::get_star_status))
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs));
// git-upload-pack (clone/fetch) — same raised body limit as receive-pack so
// large pack responses from the server don't get truncated on the client side.
let git_read_routes = Router::new()
.route("/{owner}/{repo}/info/refs", get(repos::git_info_refs))
.route(
"/{owner}/{repo}/git-upload-pack",
post(repos::git_upload_pack),
)
.route(
"/api/v1/repos/{owner}/{repo}/withheld-paths",
axum::routing::get(visibility::withheld_paths),
)
.route("/{owner}/{repo}/git-upload-pack", post(repos::git_upload_pack))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(pack_limit))
.layer(middleware::from_fn(auth::optional_signature));
.layer(RequestBodyLimitLayer::new(pack_limit));
// ── Meta ──────────────────────────────────────────────────────────────
let meta_routes = Router::new()
@@ -374,15 +179,12 @@ pub fn build_router(state: AppState) -> Router {
.merge(task_read_routes)
.merge(bounty_write_routes)
.merge(bounty_read_routes)
.merge(profile_write_routes)
.merge(creation_routes)
.merge(write_routes)
.merge(git_write_routes)
.merge(git_read_routes)
.merge(issue_write_routes)
.merge(read_routes)
.merge(peer_read_routes)
.merge(peer_write_routes)
.merge(ipfs_routes)
.merge(arweave_routes)
.merge(meta_routes)
@@ -420,12 +222,7 @@ async fn node_info(State(state): State<AppState>) -> Json<serde_json::Value> {
}
async fn stats(State(state): State<AppState>) -> Json<serde_json::Value> {
let repos = state
.db
.list_all_repos()
.await
.map(|r| r.len() as i64)
.unwrap_or(0);
let repos = state.db.list_all_repos().await.map(|r| r.len() as i64).unwrap_or(0);
let agents = state.db.count_agents().await.unwrap_or(0);
let pushes = state.db.count_pushes().await.unwrap_or(0);
Json(json!({
@@ -440,11 +237,7 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
let did_registry = &state.config.contract_did_registry;
let name_registry = &state.config.contract_name_registry;
let rpc_url = &state.config.chain_rpc_url;
let chain_id: u64 = if rpc_url.contains("sepolia") {
84532
} else {
8453
};
let chain_id: u64 = if rpc_url.contains("sepolia") { 84532 } else { 8453 };
Json(serde_json::json!({
"chain": if chain_id == 8453 { "base" } else { "base-sepolia" },
"chain_id": chain_id,
@@ -462,18 +255,11 @@ async fn contracts_info(State(state): State<AppState>) -> Json<serde_json::Value
async fn p2p_info(State(state): State<AppState>) -> Json<serde_json::Value> {
match &state.p2p {
Some(h) => {
let status = h.status().await;
Json(json!({
"enabled": true,
"peer_id": h.local_peer_id.to_string(),
"topics": [crate::p2p::REF_UPDATES_TOPIC],
"connected_peers": status.as_ref().map(|s| s.connected_peers),
"gossipsub_mesh_peers": status.as_ref().map(|s| s.gossipsub_mesh_peers),
"gossipsub_all_peers": status.as_ref().map(|s| s.gossipsub_all_peers),
"listen_addrs": status.as_ref().map(|s| s.listen_addrs.clone()),
}))
}
Some(h) => Json(json!({
"enabled": true,
"peer_id": h.local_peer_id.to_string(),
"topics": [crate::p2p::REF_UPDATES_TOPIC],
})),
None => Json(json!({ "enabled": false })),
}
}
+1 -41
View File
@@ -1,12 +1,11 @@
use std::sync::Arc;
use gitlawb_core::did::Did;
use gitlawb_core::identity::Keypair;
use std::sync::Arc;
use crate::config::Config;
use crate::db::Db;
use crate::git::repo_store::RepoStore;
use crate::p2p::P2pHandle;
use crate::rate_limit::RateLimiter;
#[derive(Clone, Debug)]
pub struct RefUpdateBroadcast {
@@ -49,43 +48,4 @@ pub struct AppState {
pub machine_id: Option<String>,
/// Centralized repo storage: local disk cache + optional Tigris backend
pub repo_store: RepoStore,
/// Per-DID rate limiter for creation endpoints (repos, issues, PRs)
pub rate_limiter: RateLimiter,
/// Process-wide graceful-shutdown signal. Sending `true` causes every
/// task that holds a `watch::Receiver` to exit at its next await point.
/// Used by:
/// * the SIGINT/SIGTERM handler in `main()`
/// * axum's `with_graceful_shutdown` to drain in-flight HTTP requests
/// * the libp2p swarm task
/// * the gossip, sync, operator heartbeat, and rate-limit cleanup loops
pub shutdown_tx: tokio::sync::watch::Sender<bool>,
}
impl AppState {
/// Subscribe to the shutdown signal. Returns a fresh receiver whose
/// initial value matches the current state.
pub fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<bool> {
self.shutdown_tx.subscribe()
}
/// Trigger graceful shutdown. Idempotent — calling more than once
/// has no effect. Returns `true` if this call was the one that
/// flipped the signal.
#[allow(dead_code)] // used by tests; main() drives the signal directly
pub fn shutdown(&self) -> bool {
self.shutdown_tx.send_if_modified(|v| {
if *v {
false
} else {
*v = true;
true
}
})
}
/// `true` once shutdown has been signalled.
#[allow(dead_code)] // used by tests and any future handler that wants to short-circuit
pub fn is_shutting_down(&self) -> bool {
*self.shutdown_tx.borrow()
}
}
+30 -467
View File
@@ -9,95 +9,34 @@
//! 2. If the repo doesn't exist locally → `git clone --mirror`.
//! 3. If it exists → `git fetch --prune` from the origin.
//! 4. Mark done or failed.
//! 5. On success, register ourselves as a replica with the origin node so
//! its `replica_count` reflects reality (best-effort, idempotent).
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use gitlawb_core::identity::Keypair;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::Db;
/// How to mirror a repo, decided from the origin's `withheld-paths` answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MirrorMode {
/// No withheld content: a normal full mirror.
Plain,
/// Withheld content present: a promisor mirror that tolerates the blobs the
/// origin omits for an anonymous caller.
Promisor,
}
/// Decide the mirror mode from the origin's `withheld-paths` response.
///
/// `Some(non-empty)` → the repo has a private subtree → `Promisor`.
/// `Some(empty)` → fully public → `Plain`.
/// `None` → the lookup 404'd or failed. Attempt a `Plain` mirror; a
/// mode-A repo also 404s the git read endpoint, so the clone
/// fails and nothing is mirrored (fail-closed at the git
/// layer), while a public repo on a peer that predates the
/// `withheld-paths` route still gets mirrored.
fn classify_mirror(withheld: Option<Vec<String>>) -> MirrorMode {
match withheld {
Some(globs) if !globs.is_empty() => MirrorMode::Promisor,
_ => MirrorMode::Plain,
}
}
use crate::config::Config;
/// Start the background sync worker. Returns immediately; the worker runs
/// as a detached tokio task that exits cleanly when `shutdown_rx` flips
/// to `true`.
pub fn start(
db: Arc<Db>,
config: Arc<Config>,
keypair: Arc<Keypair>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
/// as a detached tokio task.
pub fn start(db: Arc<Db>, config: Arc<Config>) {
tokio::spawn(async move {
run(db, config, keypair, &mut shutdown_rx).await;
run(db, config).await;
});
}
async fn run(
db: Arc<Db>,
config: Arc<Config>,
keypair: Arc<Keypair>,
shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
) {
async fn run(db: Arc<Db>, config: Arc<Config>) {
let machine_id = std::env::var("FLY_MACHINE_ID").ok();
// Bound each peer HTTP call (withheld-paths lookup + replica registration)
// so a stalled peer cannot hang the worker.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
info!("sync worker started (auto_sync=true)");
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
process_batch(&db, &config, &keypair, machine_id.as_deref(), &client).await;
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("sync worker: shutdown signal received, exiting");
return;
}
}
}
interval.tick().await;
process_batch(&db, &config, machine_id.as_deref()).await;
}
}
async fn process_batch(
db: &Db,
config: &Config,
keypair: &Keypair,
machine_id: Option<&str>,
client: &reqwest::Client,
) {
async fn process_batch(db: &Db, config: &Config, machine_id: Option<&str>) {
let items = match db.dequeue_pending_syncs(10).await {
Ok(v) => v,
Err(e) => {
@@ -137,8 +76,7 @@ async fn process_batch(
continue;
}
};
let local_path = config
.repos_dir
let local_path = config.repos_dir
.join(owner_short)
.join(format!("{repo_name}.git"));
@@ -146,189 +84,36 @@ async fn process_batch(
// (no .git suffix — the server routes don't include it)
let remote_url = format!("{}/{}", origin_url, item.repo);
let withheld = fetch_withheld(client, &origin_url, owner_short, repo_name).await;
let mode = classify_mirror(withheld);
let result = if local_path.exists() {
fetch_repo(&local_path, &remote_url, mode).await
fetch_repo(&local_path, &remote_url).await
} else {
clone_repo(&remote_url, &local_path, mode).await
clone_repo(&remote_url, &local_path).await
};
match result {
Ok(()) => {
info!(repo = %item.repo, origin = %origin_url, "synced repo from peer");
// Register in DB so git smart HTTP can serve the mirrored repo
let _ = db
.upsert_mirror_repo(
owner_short,
repo_name,
local_path.to_str().unwrap_or(""),
machine_id,
)
.await;
let _ = db.mark_sync_done(&item.id).await;
crate::metrics::record_sync_processed("done");
// Tell the origin we now host a replica so its replica_count
// reflects reality. Best-effort: idempotent on the origin and
// never fails the sync.
register_replica_with_origin(
client,
keypair,
config.public_url.as_deref(),
&origin_url,
let _ = db.upsert_mirror_repo(
owner_short,
repo_name,
)
.await;
local_path.to_str().unwrap_or(""),
machine_id,
).await;
let _ = db.mark_sync_done(&item.id).await;
}
Err(e) => {
warn!(repo = %item.repo, origin = %origin_url, err = %e, "repo sync failed");
let _ = db.mark_sync_failed(&item.id).await;
crate::metrics::record_sync_processed("failed");
}
}
}
}
/// Query the origin's anonymous `withheld-paths` endpoint. Returns the withheld
/// glob list on a 2xx, or `None` on any non-success / network / parse error
/// (treated as "unknown" by `classify_mirror`).
async fn fetch_withheld(
client: &reqwest::Client,
origin_url: &str,
owner: &str,
repo: &str,
) -> Option<Vec<String>> {
let url = format!("{origin_url}/api/v1/repos/{owner}/{repo}/withheld-paths");
let resp = client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
let globs = body
.get("withheld")?
.as_array()?
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
Some(globs)
}
/// Signed request path for replica registration on the origin node.
fn replica_registration_path(owner: &str, repo: &str) -> String {
format!("/api/v1/repos/{owner}/{repo}/replicas")
}
/// Best-effort `PUT /api/v1/repos/{owner}/{repo}/replicas` against the origin
/// node after a successful mirror, signed with our node keypair. The origin
/// records (our DID, our public URL) and exposes it via its public replica
/// list. PUT is idempotent there (201 on first registration, 200 after), so
/// re-registering on every successful sync is safe and self-healing.
///
/// Skipped when we have no public URL to advertise. Failures are logged and
/// never affect the sync result. Reuses the worker's shared `client` (30s
/// timeout) with a tighter per-request timeout.
async fn register_replica_with_origin(
client: &reqwest::Client,
keypair: &Keypair,
public_url: Option<&str>,
origin_url: &str,
owner: &str,
repo: &str,
) {
let self_url = match public_url {
Some(u) if !u.is_empty() => u,
_ => return,
};
let path = replica_registration_path(owner, repo);
let body = serde_json::json!({ "url": self_url });
let body_bytes = match serde_json::to_vec(&body) {
Ok(b) => b,
Err(e) => {
warn!(owner, repo, err = %e, "failed to serialize replica registration");
return;
}
};
let signed = gitlawb_core::http_sig::sign_request(keypair, "PUT", &path, &body_bytes);
match client
.put(format!("{origin_url}{path}"))
.header("Content-Type", "application/json")
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature)
.body(body_bytes)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
{
Ok(r) if r.status().is_success() => {
info!(owner, repo, origin = %origin_url, "registered as replica with origin");
}
Ok(r) => {
warn!(owner, repo, origin = %origin_url, status = %r.status(), "replica registration rejected by origin");
}
Err(e) => {
warn!(owner, repo, origin = %origin_url, err = %e, "replica registration request failed");
}
}
}
/// Run a git subprocess, returning an error with stderr on non-zero exit.
async fn git_run(args: &[&str]) -> anyhow::Result<()> {
let out = tokio::process::Command::new("git")
.args(args)
.output()
.await
.map_err(|e| anyhow::anyhow!("git failed to spawn: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow::anyhow!("git {args:?} failed: {stderr}"));
}
Ok(())
}
/// Run a git subprocess, ignoring a non-zero exit. Used for idempotent
/// `config --unset`, which exits non-zero when the key is already absent.
async fn git_run_lenient(args: &[&str]) {
let _ = tokio::process::Command::new("git")
.args(args)
.output()
.await;
}
/// Read a single git config value; `None` if unset or on error.
async fn git_config_get(repo: &str, key: &str) -> Option<String> {
let out = tokio::process::Command::new("git")
.args(["-C", repo, "config", "--get", key])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
/// Mirror-clone a repo from a remote URL into a local bare repo.
/// `Promisor` mode adds `--filter=blob:limit=10g`, which marks the repo a git
/// promisor (so a pack with origin-omitted withheld blobs is accepted) while
/// the huge size limit means every blob the origin *does* send is kept.
async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> anyhow::Result<()> {
let local_str = local_path.to_str().unwrap_or(".");
let mut args = vec!["clone", "--mirror"];
if mode == MirrorMode::Promisor {
args.push("--filter=blob:limit=10g");
}
args.push(remote_url);
args.push(local_str);
async fn clone_repo(remote_url: &str, local_path: &PathBuf) -> anyhow::Result<()> {
let out = tokio::process::Command::new("git")
.args(&args)
.args(["clone", "--mirror", remote_url, local_path.to_str().unwrap_or(".")])
.output()
.await
.map_err(|e| anyhow::anyhow!("git clone failed to spawn: {e}"))?;
@@ -340,239 +125,17 @@ async fn clone_repo(remote_url: &str, local_path: &Path, mode: MirrorMode) -> an
Ok(())
}
/// Fetch all refs from the remote into an existing mirror repo. Refreshes the
/// stored `origin` URL (the peer's URL may have changed) and fetches via the
/// `origin` remote so any stored promisor settings are honored.
///
/// `Promisor` applies the promisor config first (covers a repo that became
/// mode-B after a plain initial mirror). `Plain` on a mirror that was previously
/// a promisor (the repo went private -> public) clears the partial-clone config
/// and `--refetch`es, so the once-withheld, now-public blobs are backfilled
/// rather than left permanently missing.
async fn fetch_repo(local_path: &Path, remote_url: &str, mode: MirrorMode) -> anyhow::Result<()> {
let local_str = local_path.to_str().unwrap_or(".");
/// Fetch all refs from the remote into an existing mirror repo.
async fn fetch_repo(local_path: &PathBuf, remote_url: &str) -> anyhow::Result<()> {
let out = tokio::process::Command::new("git")
.args(["-C", local_path.to_str().unwrap_or("."), "fetch", "--prune", remote_url, "+refs/*:refs/*"])
.output()
.await
.map_err(|e| anyhow::anyhow!("git fetch failed to spawn: {e}"))?;
git_run(&["-C", local_str, "remote", "set-url", "origin", remote_url]).await?;
match mode {
MirrorMode::Promisor => {
git_run(&["-C", local_str, "config", "remote.origin.promisor", "true"]).await?;
git_run(&[
"-C",
local_str,
"config",
"remote.origin.partialclonefilter",
"blob:limit=10g",
])
.await?;
git_run(&["-C", local_str, "fetch", "--prune", "origin"]).await
}
MirrorMode::Plain => {
let was_promisor = git_config_get(local_str, "remote.origin.promisor")
.await
.as_deref()
== Some("true");
if was_promisor {
git_run_lenient(&[
"-C",
local_str,
"config",
"--unset",
"remote.origin.promisor",
])
.await;
git_run_lenient(&[
"-C",
local_str,
"config",
"--unset",
"remote.origin.partialclonefilter",
])
.await;
git_run(&["-C", local_str, "fetch", "--refetch", "--prune", "origin"]).await
} else {
git_run(&["-C", local_str, "fetch", "--prune", "origin"]).await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn classify_promisor_when_withheld_nonempty() {
let mode = classify_mirror(Some(vec!["/secret/**".to_string()]));
assert!(matches!(mode, MirrorMode::Promisor));
}
#[test]
fn classify_plain_when_withheld_empty() {
let mode = classify_mirror(Some(vec![]));
assert!(matches!(mode, MirrorMode::Plain));
}
#[test]
fn classify_plain_when_lookup_failed() {
// None == 404 / network error / parse failure: attempt a plain mirror
// and let the git read endpoint fail-close a mode-A repo.
let mode = classify_mirror(None);
assert!(matches!(mode, MirrorMode::Plain));
}
fn g(args: &[&str], dir: &Path) {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
}
/// Build a bare remote containing `files`, committed on one branch.
/// Returns (tempdir, file:// url). file:// makes git honor --filter.
fn bare_remote(files: &[(&str, &[u8])]) -> (TempDir, String) {
let td = TempDir::new().unwrap();
let origin = td.path().join("origin");
let bare = td.path().join("bare.git");
for (path, contents) in files {
let full = origin.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, contents).unwrap();
}
g(&["init", "-q"], &origin);
g(&["config", "user.email", "t@t"], &origin);
g(&["config", "user.name", "t"], &origin);
g(&["add", "."], &origin);
g(&["commit", "-qm", "init"], &origin);
g(
&[
"clone",
"-q",
"--bare",
origin.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
let url = format!("file://{}", bare.display());
(td, url)
}
fn git_config(repo: &Path, key: &str) -> String {
let out = Command::new("git")
.args(["-C", repo.to_str().unwrap(), "config", "--get", key])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn object_count(repo: &Path) -> usize {
let out = Command::new("git")
.args([
"-C",
repo.to_str().unwrap(),
"cat-file",
"--batch-all-objects",
"--batch-check=%(objectname)",
])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
}
#[tokio::test]
async fn promisor_clone_marks_promisor_and_keeps_objects() {
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("secret/b.txt", b"SECRET\n")]);
let dest = td.path().join("mirror.git");
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
assert_eq!(git_config(&dest, "remote.origin.promisor"), "true");
assert_eq!(git_config(&dest, "remote.origin.mirror"), "true");
// No withholding on a plain bare origin, so every object is present:
// 1 commit + 1 root tree + 2 subtrees + 2 blobs = 6.
assert_eq!(object_count(&dest), 6);
}
#[tokio::test]
async fn plain_clone_is_not_promisor() {
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
let dest = td.path().join("mirror.git");
clone_repo(&url, &dest, MirrorMode::Plain).await.unwrap();
assert_eq!(git_config(&dest, "remote.origin.promisor"), "");
assert_eq!(git_config(&dest, "remote.origin.mirror"), "true");
}
#[tokio::test]
async fn promisor_fetch_updates_existing_mirror() {
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
let dest = td.path().join("mirror.git");
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
let before = object_count(&dest);
// Add a second commit to the origin working tree and push to the bare
// (the working repo has no named remote, so push via the file:// URL).
let origin = td.path().join("origin");
std::fs::write(origin.join("public/c.txt"), b"more\n").unwrap();
g(&["add", "."], &origin);
g(&["commit", "-qm", "second"], &origin);
g(&["push", "-q", &url, "HEAD"], &origin);
fetch_repo(&dest, &url, MirrorMode::Promisor).await.unwrap();
assert_eq!(git_config(&dest, "remote.origin.promisor"), "true");
assert!(object_count(&dest) > before, "fetch pulled the new commit");
}
#[tokio::test]
async fn plain_fetch_clears_promisor_config_on_transition() {
// Repo started mode-B (promisor mirror), then went fully public, so the
// next sync classifies Plain. fetch_repo must drop the partial-clone
// config and refetch instead of leaving the mirror a promisor forever.
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n")]);
let dest = td.path().join("mirror.git");
clone_repo(&url, &dest, MirrorMode::Promisor).await.unwrap();
assert_eq!(git_config(&dest, "remote.origin.promisor"), "true");
fetch_repo(&dest, &url, MirrorMode::Plain).await.unwrap();
assert_eq!(git_config(&dest, "remote.origin.promisor"), "");
assert_eq!(git_config(&dest, "remote.origin.partialclonefilter"), "");
}
#[test]
fn registration_path_matches_replicas_route() {
// Must stay in sync with the route in api/mod.rs:
// PUT /api/v1/repos/:owner/:repo/replicas
assert_eq!(
replica_registration_path("z6MkOwner", "my-repo"),
"/api/v1/repos/z6MkOwner/my-repo/replicas"
);
}
#[tokio::test]
async fn registration_skipped_without_public_url() {
// No public URL to advertise → must return without sending anything.
// An unroutable origin URL would otherwise surface as a warn + delay.
let client = reqwest::Client::new();
let keypair = Keypair::generate();
register_replica_with_origin(
&client,
&keypair,
None,
"http://127.0.0.1:1", // would fail instantly if contacted
"owner",
"repo",
)
.await;
register_replica_with_origin(&client, &keypair, Some(""), "http://127.0.0.1:1", "o", "r")
.await;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow::anyhow!("git fetch failed: {stderr}"));
}
Ok(())
}
-374
View File
@@ -1,374 +0,0 @@
//! Pure read-authorization logic for path-scoped visibility.
//!
//! `visibility_check` decides whether a caller may read a given path in a repo,
//! based on the repo's visibility rules with a fallback to the legacy
//! `is_public` flag. It performs no I/O so it is exhaustively unit tested.
use crate::db::VisibilityRule;
#[derive(Debug, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
}
/// True if `caller` is the repo owner (matches full did:key or its short form),
/// mirroring the owner-match idiom in `api/protect.rs`.
fn is_owner(owner_did: &str, caller: &str) -> bool {
let owner_short = owner_did.split(':').next_back().unwrap_or(owner_did);
caller == owner_did || caller == owner_short
}
/// The match prefix for a glob: "/" stays "/", "/secret/**" becomes "/secret".
fn glob_prefix(glob: &str) -> &str {
let p = glob.trim_end_matches("**").trim_end_matches('/');
if p.is_empty() {
"/"
} else {
p
}
}
/// Does `glob` match `path`? "/" matches everything; "/secret" matches
/// "/secret" and any "/secret/..." descendant.
fn glob_matches(glob: &str, path: &str) -> bool {
let prefix = glob_prefix(glob);
if prefix == "/" {
return true;
}
path == prefix || path.starts_with(&format!("{prefix}/"))
}
/// Specificity = length of the match prefix; longer is more specific.
fn specificity(glob: &str) -> usize {
glob_prefix(glob).len()
}
/// Decide whether `caller` (None = anonymous) may read `path` in a repo.
/// `path` is "/" for a whole-repo clone/fetch.
///
/// Reader DIDs in a rule are matched exactly, so they must be stored in full
/// `did:key:...` form. The owner is the only identity matched in both full and
/// short form.
pub fn visibility_check(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
path: &str,
) -> Decision {
// The owner can always read everything.
if let Some(c) = caller {
if is_owner(owner_did, c) {
return Decision::Allow;
}
}
// Most-specific matching rule wins. On equal specificity the last rule in
// DB order is chosen; `list_visibility_rules` orders by `path_glob`, so this
// is deterministic.
let best = rules
.iter()
.filter(|r| glob_matches(&r.path_glob, path))
.max_by_key(|r| specificity(&r.path_glob));
match best {
Some(rule) => {
// Phase 1 treats every matching rule as an allow-list keyed by
// `reader_dids`. `rule.mode` (A vs B) is stored from day one but not
// acted on here; it governs replication behavior in Phases 2-3.
let allowed = caller
.map(|c| rule.reader_dids.iter().any(|d| d == c))
.unwrap_or(false);
if allowed {
Decision::Allow
} else {
Decision::Deny
}
}
None => {
if is_public {
Decision::Allow
} else {
Decision::Deny
}
}
}
}
/// The subtree path globs that `caller` (None = anonymous) may NOT read, given
/// the repo's rules. Whole-repo ("/") rules are excluded: a denied whole-repo
/// read is handled by the 404 gate before a clone ever starts. Each remaining
/// rule is reported when `visibility_check` denies the caller at the glob's
/// representative path. Used by the clean-clone client to sparse-exclude the
/// private paths from checkout.
pub fn withheld_globs(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Vec<String> {
rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
let probe = glob_prefix(&r.path_glob);
visibility_check(rules, is_public, owner_did, caller, probe) == Decision::Deny
})
.map(|r| r.path_glob.clone())
.collect()
}
/// The allowed globs that sit strictly underneath a denied glob. A clean-clone
/// client sparse-excludes everything in `withheld_globs`, which would also hide
/// these nested allowed paths; re-including them restores the caller's access.
/// Example: with `/secret/**` denied and `/secret/public/**` allowed for the
/// same caller, `/secret/public/**` is returned here so the client re-includes
/// it after excluding `/secret/`.
pub fn reincluded_globs(
rules: &[VisibilityRule],
is_public: bool,
owner_did: &str,
caller: Option<&str>,
) -> Vec<String> {
let denied: Vec<&str> = rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
visibility_check(
rules,
is_public,
owner_did,
caller,
glob_prefix(&r.path_glob),
) == Decision::Deny
})
.map(|r| glob_prefix(&r.path_glob))
.collect();
rules
.iter()
.filter(|r| r.path_glob != "/")
.filter(|r| {
visibility_check(
rules,
is_public,
owner_did,
caller,
glob_prefix(&r.path_glob),
) == Decision::Allow
})
.filter(|r| {
let p = glob_prefix(&r.path_glob);
denied
.iter()
.any(|d| *d != p && p.starts_with(&format!("{d}/")))
})
.map(|r| r.path_glob.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::VisibilityMode;
use chrono::Utc;
fn rule(path_glob: &str, mode: VisibilityMode, readers: &[&str]) -> VisibilityRule {
VisibilityRule {
id: "x".into(),
repo_id: "r1".into(),
path_glob: path_glob.into(),
mode,
reader_dids: readers.iter().map(|s| s.to_string()).collect(),
created_by: "did:key:z6MkOwner".into(),
created_at: Utc::now(),
}
}
const OWNER: &str = "did:key:z6MkOwner";
#[test]
fn withheld_globs_lists_only_denied_subtrees() {
let rules = [
rule("/secret/**", VisibilityMode::B, &["did:key:z6MkFriend"]),
rule("/docs/**", VisibilityMode::B, &["did:key:z6MkStranger"]),
];
// Stranger is denied /secret but allowed /docs.
let mut got = withheld_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
got.sort();
assert_eq!(got, vec!["/secret/**".to_string()]);
// Owner is denied nothing.
assert!(withheld_globs(&rules, true, OWNER, Some(OWNER)).is_empty());
// Anonymous is denied both.
let mut anon = withheld_globs(&rules, true, OWNER, None);
anon.sort();
assert_eq!(anon, vec!["/docs/**".to_string(), "/secret/**".to_string()]);
}
#[test]
fn reincluded_globs_restores_allowed_nested_path() {
let rules = [
rule("/secret/**", VisibilityMode::B, &["did:key:z6MkFriend"]),
rule(
"/secret/public/**",
VisibilityMode::B,
&["did:key:z6MkFriend", "did:key:z6MkStranger"],
),
];
// Stranger is denied /secret/** but allowed the nested /secret/public/**.
let withheld = withheld_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
assert_eq!(withheld, vec!["/secret/**".to_string()]);
let reinc = reincluded_globs(&rules, true, OWNER, Some("did:key:z6MkStranger"));
assert_eq!(reinc, vec!["/secret/public/**".to_string()]);
// Owner is denied nothing, so there is nothing to re-include.
assert!(reincluded_globs(&rules, true, OWNER, Some(OWNER)).is_empty());
}
#[test]
fn no_rules_public_allows_anonymous() {
assert_eq!(
visibility_check(&[], true, OWNER, None, "/"),
Decision::Allow
);
}
#[test]
fn no_rules_private_denies_anonymous() {
assert_eq!(
visibility_check(&[], false, OWNER, None, "/"),
Decision::Deny
);
}
#[test]
fn root_rule_denies_anonymous() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/"),
Decision::Deny
);
}
#[test]
fn root_rule_allows_owner() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, true, OWNER, Some(OWNER), "/"),
Decision::Allow
);
}
#[test]
fn root_rule_allows_owner_short_form() {
let rules = [rule("/", VisibilityMode::A, &[])];
assert_eq!(
visibility_check(&rules, true, OWNER, Some("z6MkOwner"), "/"),
Decision::Allow
);
}
#[test]
fn root_rule_allows_listed_reader() {
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
assert_eq!(
visibility_check(&rules, true, OWNER, Some("did:key:z6MkFriend"), "/"),
Decision::Allow
);
}
#[test]
fn root_rule_denies_unlisted_reader() {
let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];
assert_eq!(
visibility_check(&rules, true, OWNER, Some("did:key:z6MkStranger"), "/"),
Decision::Deny
);
}
#[test]
fn subtree_rule_matches_descendant_path() {
let rules = [rule(
"/secret/**",
VisibilityMode::B,
&["did:key:z6MkFriend"],
)];
assert_eq!(
visibility_check(
&rules,
true,
OWNER,
Some("did:key:z6MkStranger"),
"/secret/a.rs"
),
Decision::Deny
);
assert_eq!(
visibility_check(
&rules,
true,
OWNER,
Some("did:key:z6MkFriend"),
"/secret/a.rs"
),
Decision::Allow
);
}
#[test]
fn subtree_rule_does_not_affect_root_clone() {
// A subtree rule must not gate a whole-repo (path "/") read: the public
// fallback applies because the subtree glob does not match "/".
let rules = [rule("/secret/**", VisibilityMode::B, &[])];
assert_eq!(
visibility_check(&rules, true, OWNER, None, "/"),
Decision::Allow
);
}
#[test]
fn most_specific_rule_wins() {
// Public repo, but /secret is locked. A stranger reading /secret is denied
// by the more specific rule even though "/" would allow.
let rules = [
rule("/", VisibilityMode::A, &["did:key:z6MkStranger"]),
rule("/secret/**", VisibilityMode::B, &["did:key:z6MkFriend"]),
];
// stranger is a root reader but not a /secret reader
assert_eq!(
visibility_check(
&rules,
true,
OWNER,
Some("did:key:z6MkStranger"),
"/secret/a.rs"
),
Decision::Deny
);
// stranger can still read root
assert_eq!(
visibility_check(&rules, true, OWNER, Some("did:key:z6MkStranger"), "/"),
Decision::Allow
);
}
// Mirrors the gossip-announce gate in git_receive_pack: announce iff an
// anonymous caller can read "/".
#[test]
fn announce_gate_matches_public_readability() {
let announce = |rules: &[VisibilityRule], is_public: bool| {
visibility_check(rules, is_public, OWNER, None, "/") == Decision::Allow
};
// Public repo, no rules → announce.
assert!(announce(&[], true));
// Legacy private repo (is_public false, no rules) → silent.
assert!(!announce(&[], false));
// Mode A whole-repo rule with no public readers → silent.
assert!(!announce(&[rule("/", VisibilityMode::A, &[])], true));
// Mode B public repo with a private subtree → still announce.
assert!(announce(
&[rule("/secret/**", VisibilityMode::B, &[])],
true
));
}
}
+14 -25
View File
@@ -24,8 +24,8 @@ type HmacSha256 = Hmac<Sha256>;
/// Compute `sha256=<hex>` HMAC signature for a webhook payload.
fn sign_payload(secret: &str, payload: &[u8]) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC accepts any key length");
mac.update(payload);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
@@ -93,29 +93,18 @@ async fn fire_event_async(
}
match req.send().await {
Ok(resp) => {
let result_label = if resp.status().is_success() {
"ok"
} else {
"http_error"
};
crate::metrics::record_webhook_delivery(result_label);
tracing::info!(
url = %hook.url,
event = %event_name,
status = %resp.status(),
"webhook delivered"
)
}
Err(e) => {
crate::metrics::record_webhook_delivery("network_error");
tracing::warn!(
url = %hook.url,
event = %event_name,
err = %e,
"webhook delivery failed"
)
}
Ok(resp) => tracing::info!(
url = %hook.url,
event = %event_name,
status = %resp.status(),
"webhook delivered"
),
Err(e) => tracing::warn!(
url = %hook.url,
event = %event_name,
err = %e,
"webhook delivery failed"
),
}
});
}
+13 -50
View File
@@ -36,11 +36,7 @@ pub enum AgentCmd {
pub async fn run(args: AgentArgs) -> Result<()> {
match args.cmd {
AgentCmd::List {
node,
capability,
dir: _,
} => cmd_list(node, capability).await,
AgentCmd::List { node, capability, dir: _ } => cmd_list(node, capability).await,
AgentCmd::Show { did, node } => cmd_show(did, node).await,
}
}
@@ -53,10 +49,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
None => "/api/v1/agents".to_string(),
};
let resp = client
.get(&path)
.await
.context("failed to connect to node")?;
let resp = client.get(&path).await.context("failed to connect to node")?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
@@ -66,10 +59,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
);
}
let body: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
let body: Value = resp.json().await.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
@@ -86,20 +76,10 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
println!("Agents on {} ({} total)\n", node, agents.len());
for agent in &agents {
let did = agent["did"].as_str().unwrap_or("?");
let short = did
.split(':')
.next_back()
.map(|s| &s[..s.len().min(16)])
.unwrap_or("?");
let short = did.split(':').next_back().map(|s| &s[..s.len().min(16)]).unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
let caps = agent["capabilities"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_default();
let model = agent["model"].as_str().unwrap_or("");
println!(" {short}… trust={trust:.2} {caps}");
@@ -126,10 +106,7 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
);
}
let agent: Value = resp
.json()
.await
.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
let agent: Value = resp.json().await.context("invalid JSON from node — is GITLAWB_NODE correct?")?;
if !status.is_success() {
let msg = agent["message"].as_str().unwrap_or("unknown error");
@@ -138,14 +115,8 @@ async fn cmd_show(did: String, node: String) -> Result<()> {
let full_did = agent["did"].as_str().unwrap_or("?");
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
let caps = agent["capabilities"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
let caps = agent["capabilities"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_default();
let registered = agent["registered_at"].as_str().unwrap_or("?");
let model = agent["model"].as_str().unwrap_or("(none)");
@@ -201,9 +172,7 @@ mod tests {
.create_async()
.await;
cmd_list(server.url(), Some("git:push".to_string()))
.await
.unwrap();
cmd_list(server.url(), Some("git:push".to_string())).await.unwrap();
_m.assert_async().await;
}
@@ -248,9 +217,7 @@ mod tests {
.create_async()
.await;
cmd_show("did:key:z6MkTest".to_string(), server.url())
.await
.unwrap();
cmd_show("did:key:z6MkTest".to_string(), server.url()).await.unwrap();
}
#[tokio::test]
@@ -264,9 +231,7 @@ mod tests {
.create_async()
.await;
let err = cmd_show("did:key:z6MkMissing".to_string(), server.url())
.await
.unwrap_err();
let err = cmd_show("did:key:z6MkMissing".to_string(), server.url()).await.unwrap_err();
assert!(err.to_string().contains("agents API") || err.to_string().contains("not found"));
}
@@ -281,8 +246,6 @@ mod tests {
.create_async()
.await;
cmd_show("did:key:z6MkHigh".to_string(), server.url())
.await
.unwrap();
cmd_show("did:key:z6MkHigh".to_string(), server.url()).await.unwrap();
}
}
+26 -102
View File
@@ -111,31 +111,14 @@ pub enum BountyCmd {
pub async fn run(args: BountyArgs) -> Result<()> {
match args.cmd {
BountyCmd::Create {
repo,
title,
amount,
issue,
tx_hash,
deadline,
node,
dir,
} => cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await,
BountyCmd::Create { repo, title, amount, issue, tx_hash, deadline, node, dir } => {
cmd_create(repo, title, amount, issue, tx_hash, deadline, node, dir).await
}
BountyCmd::List { repo, status, node } => cmd_list(repo, status, node).await,
BountyCmd::Show { id, node } => cmd_show(id, node).await,
BountyCmd::Claim {
id,
wallet,
node,
dir,
} => cmd_claim(id, wallet, node, dir).await,
BountyCmd::Claim { id, wallet, node, dir } => cmd_claim(id, wallet, node, dir).await,
BountyCmd::Submit { id, pr, node, dir } => cmd_submit(id, pr, node, dir).await,
BountyCmd::Approve {
id,
tx_hash,
node,
dir,
} => cmd_approve(id, tx_hash, node, dir).await,
BountyCmd::Approve { id, tx_hash, node, dir } => cmd_approve(id, tx_hash, node, dir).await,
BountyCmd::Cancel { id, node, dir } => cmd_cancel(id, node, dir).await,
BountyCmd::Stats { node } => cmd_stats(node).await,
}
@@ -143,19 +126,12 @@ pub async fn run(args: BountyArgs) -> Result<()> {
#[allow(clippy::too_many_arguments)]
async fn cmd_create(
repo: String,
title: String,
amount: i64,
issue: Option<String>,
tx_hash: Option<String>,
deadline: Option<i64>,
node: String,
dir: Option<PathBuf>,
repo: String, title: String, amount: i64, issue: Option<String>,
tx_hash: Option<String>, deadline: Option<i64>, node: String, dir: Option<PathBuf>,
) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let (owner, name) = repo
.split_once('/')
let (owner, name) = repo.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let client = NodeClient::new(&node, Some(kp));
@@ -169,10 +145,7 @@ async fn cmd_create(
});
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{name}/bounties"),
&serde_json::to_vec(&body)?,
)
.post(&format!("/api/v1/repos/{owner}/{name}/bounties"), &serde_json::to_vec(&body)?)
.await
.context("failed to connect to node")?;
@@ -196,8 +169,7 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
let client = NodeClient::new(&node, None);
let url = if let Some(ref repo) = repo {
let (owner, name) = repo
.split_once('/')
let (owner, name) = repo.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let mut u = format!("/api/v1/repos/{owner}/{name}/bounties");
@@ -213,10 +185,7 @@ async fn cmd_list(repo: Option<String>, status: Option<String>, node: String) ->
u
};
let resp = client
.get(&url)
.await
.context("failed to connect to node")?;
let resp = client.get(&url).await.context("failed to connect to node")?;
let body: Value = resp.json().await.unwrap_or_default();
let bounties = body["bounties"].as_array();
@@ -253,22 +222,14 @@ async fn cmd_show(id: String, node: String) -> Result<()> {
Ok(())
}
async fn cmd_claim(
id: String,
wallet: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_claim(id: String, wallet: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::json!({ "wallet": wallet });
let resp = client
.post(
&format!("/api/v1/bounties/{id}/claim"),
&serde_json::to_vec(&body)?,
)
.post(&format!("/api/v1/bounties/{id}/claim"), &serde_json::to_vec(&body)?)
.await?;
let status = resp.status();
@@ -290,10 +251,7 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
let body = serde_json::json!({ "pr_id": pr });
let resp = client
.post(
&format!("/api/v1/bounties/{id}/submit"),
&serde_json::to_vec(&body)?,
)
.post(&format!("/api/v1/bounties/{id}/submit"), &serde_json::to_vec(&body)?)
.await?;
let status = resp.status();
@@ -308,22 +266,14 @@ async fn cmd_submit(id: String, pr: String, node: String, dir: Option<PathBuf>)
Ok(())
}
async fn cmd_approve(
id: String,
tx_hash: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_approve(id: String, tx_hash: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::json!({ "tx_hash": tx_hash });
let resp = client
.post(
&format!("/api/v1/bounties/{id}/approve"),
&serde_json::to_vec(&body)?,
)
.post(&format!("/api/v1/bounties/{id}/approve"), &serde_json::to_vec(&body)?)
.await?;
let status = resp.status();
@@ -345,10 +295,7 @@ async fn cmd_cancel(id: String, node: String, dir: Option<PathBuf>) -> Result<()
let body = serde_json::json!({});
let resp = client
.post(
&format!("/api/v1/bounties/{id}/cancel"),
&serde_json::to_vec(&body)?,
)
.post(&format!("/api/v1/bounties/{id}/cancel"), &serde_json::to_vec(&body)?)
.await?;
let status = resp.status();
@@ -385,10 +332,7 @@ async fn cmd_stats(node: String) -> Result<()> {
let count = entry["completed"].as_i64().unwrap_or(0);
let earned = entry["total_earned"].as_i64().unwrap_or(0);
let short = &did[did.len().saturating_sub(8)..];
println!(
" {}. ...{short} {count} bounties {earned} $GITLAWB",
i + 1
);
println!(" {}. ...{short} {count} bounties {earned} $GITLAWB", i + 1);
}
}
}
@@ -404,11 +348,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"/bounties$".to_string()))
@@ -422,14 +362,10 @@ mod tests {
"owner/repo".to_string(),
"Fix bug".to_string(),
50000,
None,
None,
None,
None, None, None,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -439,14 +375,10 @@ mod tests {
"owner/repo".to_string(),
"Fix bug".to_string(),
50000,
None,
None,
None,
None, None, None,
"http://127.0.0.1:1".to_string(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
).await.unwrap_err();
assert!(err.to_string().contains("identity not found"));
}
@@ -470,11 +402,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", mockito::Matcher::Regex(r"/claim$".to_string()))
@@ -489,9 +417,7 @@ mod tests {
Some("0xWALLET".to_string()),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -506,9 +432,7 @@ mod tests {
.create_async()
.await;
let err = cmd_show("nonexistent".to_string(), server.url())
.await
.unwrap_err();
let err = cmd_show("nonexistent".to_string(), server.url()).await.unwrap_err();
assert!(err.to_string().contains("not found"));
}
+18 -71
View File
@@ -7,7 +7,6 @@ use clap::{Args, Subcommand};
use serde_json::Value;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct CertArgs {
@@ -42,25 +41,16 @@ pub async fn run(args: CertArgs) -> Result<()> {
}
}
/// Resolve "repo" into (owner, name) using the caller's DID when no slash is given.
/// Resolve "repo" into (owner, name) — if no slash, use the node's own DID short form.
async fn resolve_repo(repo: &str, node: &str) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
Ok((owner.to_string(), name.to_string()))
} else {
let short = if let Ok(kp) = load_keypair_from_dir(None) {
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
did.split(':').next_back().unwrap_or(did).to_string()
};
let client = NodeClient::new(node, None);
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
let short = did.split(':').next_back().unwrap_or(did).to_string();
Ok((short, repo.to_string()))
}
}
@@ -70,11 +60,7 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client
.get(&path)
.await?
.json()
.await
let resp: Value = client.get(&path).await?.json().await
.context("failed to list certificates")?;
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
@@ -87,9 +73,9 @@ async fn cmd_list(repo: String, node: String) -> Result<()> {
println!("Ref certificates for {owner}/{name}");
println!();
for cert in &certs {
let id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
}
@@ -100,23 +86,18 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node).await?;
let client = NodeClient::new(&node, None);
let id = resolve_cert_id(&client, &owner, &name, &id).await?;
// Fetch the certificate
let path = format!("/api/v1/repos/{owner}/{name}/certs/{id}");
let resp = client
.get(&path)
.await?
.error_for_status()
let cert: Value = client.get(&path).await?.json().await
.context("certificate not found")?;
let cert: Value = resp.json().await.context("certificate not found")?;
let cert_id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let old_sha = cert["old_sha"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let pusher = cert["pusher_did"].as_str().unwrap_or("?");
let node_did = cert["node_did"].as_str().unwrap_or("?");
let cert_id = cert["id"].as_str().unwrap_or("?");
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
let old_sha = cert["old_sha"].as_str().unwrap_or("?");
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
let pusher = cert["pusher_did"].as_str().unwrap_or("?");
let node_did = cert["node_did"].as_str().unwrap_or("?");
let signature = cert["signature"].as_str().unwrap_or("?");
let issued_at = cert["issued_at"].as_str().unwrap_or("?");
@@ -132,11 +113,7 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
// Reconstruct the signing payload and verify
// Fetch the node's current public key to verify
let info: Value = client
.get("/")
.await?
.json()
.await
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let current_node_did = info["did"].as_str().unwrap_or("");
@@ -162,33 +139,3 @@ async fn cmd_show(repo: String, id: String, node: String) -> Result<()> {
Ok(())
}
async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result<String> {
if id.len() >= 36 {
return Ok(id.to_string());
}
let path = format!("/api/v1/repos/{owner}/{name}/certs");
let resp: Value = client
.get(&path)
.await?
.error_for_status()
.context("failed to list certificates")?
.json()
.await
.context("failed to list certificates")?;
let certs = resp["certificates"].as_array().cloned().unwrap_or_default();
let matches: Vec<String> = certs
.iter()
.filter_map(|cert| cert["id"].as_str())
.filter(|cert_id| cert_id.starts_with(id))
.map(ToString::to_string)
.collect();
match matches.as_slice() {
[full_id] => Ok(full_id.to_string()),
[] => Ok(id.to_string()),
_ => anyhow::bail!("certificate prefix {id} matches multiple certificates"),
}
}
+9 -40
View File
@@ -42,11 +42,7 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(&args.node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -55,14 +51,8 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
};
let client = NodeClient::new(&args.node, None);
let url = format!(
"/api/v1/repos/{owner}/{name}/changelog?limit={}",
args.limit
);
let resp = client
.get(&url)
.await
.context("failed to connect to node")?;
let url = format!("/api/v1/repos/{owner}/{name}/changelog?limit={}", args.limit);
let resp = client.get(&url).await.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
@@ -115,9 +105,7 @@ fn detect_repo_from_remote() -> Option<String> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
if !out.status.success() { return None; }
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
@@ -136,11 +124,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/changelog".to_string()))
@@ -164,11 +148,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let body = r#"{
"repo":"z/myrepo",
@@ -201,11 +181,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("GET", mockito::Matcher::Regex(r"/changelog".to_string()))
@@ -230,17 +206,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/changelog\?limit=5".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"/changelog\?limit=5".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repo":"z/myrepo","events":[],"count":0}"#)
-450
View File
@@ -1,450 +0,0 @@
//! `gl clone`: clean partial clone of a gitlawb repo with private subtrees.
//!
//! A repo may withhold blob content under some path globs from the caller
//! (Phase 3). The resulting pack is not closed under reachability, so a stock
//! `git clone` is refused at fetch. This command clones as a promisor
//! (`--filter=blob:none`) and sparse-excludes the caller's withheld globs,
//! producing a clean checkout: public files present, private paths absent.
use anyhow::{bail, Context, Result};
use clap::Args;
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct CloneArgs {
/// Repo to clone: gitlawb://<owner_did>/<name> or <owner>/<name>.
pub repo: String,
/// Destination directory (default: the repo name).
pub dir: Option<String>,
/// Branch to check out (default: the remote's default branch).
#[arg(long)]
pub branch: Option<String>,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
pub node: String,
}
/// Run a git command inside `dir`, erroring with stderr on failure.
fn git(dir: &Path, args: &[&str]) -> Result<()> {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.with_context(|| format!("running git {args:?}"))?;
if !out.status.success() {
bail!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
/// Run a git command not tied to a working tree (e.g. `clone`).
fn git_global(args: &[&str]) -> Result<()> {
let out = Command::new("git")
.args(args)
.output()
.with_context(|| format!("running git {args:?}"))?;
if !out.status.success() {
bail!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
/// Sparse-checkout pattern(s) for a visibility glob. A subtree glob
/// (`/secret/**`) maps to the directory `/secret/`. A wildcard-free glob
/// (`/docs/private`) matches both the exact path and a subtree at that path
/// (mirroring the node's `glob_matches`), so it maps to both `/docs/private`
/// and `/docs/private/`. Callers prefix these with `!` to exclude.
fn sparse_patterns(glob: &str) -> Vec<String> {
match glob.strip_suffix("/**") {
Some(base) => vec![format!("{base}/")],
None => vec![glob.to_string(), format!("{glob}/")],
}
}
/// Clone `remote_url` into `dest`, excluding `withheld_globs` from checkout.
/// `dest` must not already exist. With nothing withheld this is a plain full
/// clone. With globs withheld it clones as a promisor (`--filter=blob:none`,
/// marking the repo a promisor so the node's non-closed pack is accepted)
/// without checkout, sparse-excludes each glob, then checks out so the absent
/// blobs are never materialized. `--no-cone` is required for negated excludes.
pub fn setup_partial_clone(
dest: &Path,
remote_url: &str,
withheld_globs: &[String],
reinclude_globs: &[String],
branch: Option<&str>,
) -> Result<()> {
let dest_str = dest
.to_str()
.context("destination path is not valid UTF-8")?;
if withheld_globs.is_empty() {
match branch {
Some(b) => git_global(&["clone", "-q", "--branch", b, remote_url, dest_str])?,
None => git_global(&["clone", "-q", remote_url, dest_str])?,
}
return Ok(());
}
git_global(&[
"clone",
"-q",
"--filter=blob:none",
"--no-checkout",
remote_url,
dest_str,
])?;
git(dest, &["sparse-checkout", "init", "--no-cone"])?;
// Non-cone sparse-checkout, gitignore-style: include everything, exclude the
// withheld globs, then re-include any allowed globs nested under an excluded
// one. Emitting all excludes before the re-includes is safe even for deeper
// re-denials (deny /secret, allow /secret/public, deny /secret/public/admin):
// git does not re-traverse an explicitly excluded directory, so a broader
// parent re-include never resurrects a more specific excluded subtree.
let mut spec = String::from("/*\n");
for g in withheld_globs {
for pat in sparse_patterns(g) {
spec.push('!');
spec.push_str(&pat);
spec.push('\n');
}
}
for g in reinclude_globs {
for pat in sparse_patterns(g) {
spec.push_str(&pat);
spec.push('\n');
}
}
std::fs::write(dest.join(".git/info/sparse-checkout"), spec)
.context("writing sparse-checkout spec")?;
match branch {
Some(b) => git(dest, &["checkout", "-q", b])?,
None => {
// Read the default branch from the local `origin/HEAD` symref that
// clone just set, instead of parsing `git remote show origin`, whose
// "HEAD branch:" line is localized and needs a network round-trip.
let out = Command::new("git")
.args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
.current_dir(dest)
.output()?;
if !out.status.success() {
bail!(
"could not determine default branch: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let symref = String::from_utf8_lossy(&out.stdout);
let head = symref
.trim()
.strip_prefix("origin/")
.context("unexpected origin/HEAD format")?;
git(dest, &["checkout", "-q", head])?;
}
}
Ok(())
}
/// Parse `repo` into (gitlawb_url, owner, name). Accepts a full
/// `gitlawb://<owner>/<name>` URL or a bare `<owner>/<name>`. The owner DID may
/// itself contain colons but no slash, so split on the first slash.
fn parse_repo(repo: &str) -> Result<(String, String, String)> {
let stripped = repo.strip_prefix("gitlawb://").unwrap_or(repo);
let (owner, name) = stripped
.trim_end_matches('/')
.split_once('/')
.context("repo must be <owner>/<name> or gitlawb://<owner>/<name>")?;
if owner.is_empty() || name.is_empty() || name.contains('/') {
bail!("repo must be <owner>/<name> or gitlawb://<owner>/<name>");
}
Ok((
format!("gitlawb://{owner}/{name}"),
owner.to_string(),
name.to_string(),
))
}
/// Ask the node which globs are withheld for this caller and which allowed globs
/// nested under them must be re-included. Returns `(withheld, reinclude)`. A
/// node that does not implement the endpoint (404/501) yields empties so public
/// repos on older nodes still clone normally. Other failures (network, auth,
/// 5xx, malformed JSON) are propagated: failing open here would silently fall
/// back to a stock clone, which the node refuses once blobs are withheld, hiding
/// the real cause behind a confusing fetch error.
async fn fetch_withheld(node: &str, owner: &str, name: &str) -> Result<(Vec<String>, Vec<String>)> {
let kp = load_keypair_from_dir(None).ok();
let signed = kp.is_some();
let client = NodeClient::new(node, kp);
let path = format!("/api/v1/repos/{owner}/{name}/withheld-paths");
let resp = if signed {
client.get_signed(&path).await
} else {
client.get(&path).await
};
let resp = match resp {
Ok(r) if r.status().is_success() => r,
Ok(r) if matches!(r.status().as_u16(), 404 | 501) => return Ok((Vec::new(), Vec::new())),
Ok(r) => bail!("withheld-paths lookup failed: {}", r.status()),
Err(err) => return Err(err).context("fetching withheld paths"),
};
let body: WithheldPathsResponse = resp
.json()
.await
.context("parsing withheld-paths response")?;
Ok((body.withheld, body.reinclude))
}
/// The node's `/withheld-paths` 200 body. Both fields are always emitted as JSON
/// arrays; deserializing into this struct (rather than poking at a `Value`) makes
/// a missing or mistyped field a hard error instead of silently becoming `[]`,
/// which would mask a server regression behind a confusing later clone failure.
#[derive(Deserialize)]
struct WithheldPathsResponse {
withheld: Vec<String>,
reinclude: Vec<String>,
}
pub async fn run(args: CloneArgs) -> Result<()> {
let (url, owner, name) = parse_repo(&args.repo)?;
let dest_name = args.dir.unwrap_or_else(|| name.clone());
let dest = std::path::PathBuf::from(&dest_name);
if dest.exists() {
bail!("destination '{dest_name}' already exists");
}
let (withheld, reinclude) = fetch_withheld(&args.node, &owner, &name).await?;
if withheld.is_empty() {
println!("Cloning {url} into {dest_name}");
} else {
println!(
"Cloning {url} into {dest_name} ({} private path(s) excluded)",
withheld.len()
);
}
setup_partial_clone(&dest, &url, &withheld, &reinclude, args.branch.as_deref())?;
println!("Done. Cloned into {dest_name}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn g(args: &[&str], dir: &Path) {
assert!(Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap()
.success());
}
#[test]
fn setup_partial_clone_excludes_withheld_path() {
let td = TempDir::new().unwrap();
let origin = td.path().join("origin");
let bare = td.path().join("bare.git");
std::fs::create_dir_all(origin.join("secret")).unwrap();
std::fs::create_dir_all(origin.join("public")).unwrap();
std::fs::write(origin.join("public/a.txt"), b"pub\n").unwrap();
std::fs::write(origin.join("secret/b.txt"), b"SECRET\n").unwrap();
g(&["init", "-q"], &origin);
g(&["config", "user.email", "t@t"], &origin);
g(&["config", "user.name", "t"], &origin);
g(&["add", "."], &origin);
g(&["commit", "-qm", "init"], &origin);
g(
&[
"clone",
"-q",
"--bare",
origin.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
// file:// so --filter is honored (local-path clones ignore it).
let dest = td.path().join("dest");
let url = format!("file://{}", bare.display());
setup_partial_clone(&dest, &url, &["/secret/**".to_string()], &[], None).unwrap();
assert!(dest.join("public/a.txt").exists(), "public file present");
assert!(
!dest.join("secret/b.txt").exists(),
"withheld path must be excluded from checkout"
);
}
/// Build a bare remote with the given files (relative path -> contents),
/// committed on one branch. Returns (tempdir, file:// url).
fn bare_remote(files: &[(&str, &[u8])]) -> (TempDir, String) {
let td = TempDir::new().unwrap();
let origin = td.path().join("origin");
let bare = td.path().join("bare.git");
for (path, contents) in files {
let full = origin.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, contents).unwrap();
}
g(&["init", "-q"], &origin);
g(&["config", "user.email", "t@t"], &origin);
g(&["config", "user.name", "t"], &origin);
g(&["add", "."], &origin);
g(&["commit", "-qm", "init"], &origin);
g(
&[
"clone",
"-q",
"--bare",
origin.to_str().unwrap(),
bare.to_str().unwrap(),
],
td.path(),
);
let url = format!("file://{}", bare.display());
(td, url)
}
#[test]
fn reinclude_restores_allowed_nested_path() {
let (td, url) = bare_remote(&[
("public/a.txt", b"pub\n"),
("secret/private/p.txt", b"PRIV\n"),
("secret/public/s.txt", b"SHARED\n"),
]);
let dest = td.path().join("dest");
setup_partial_clone(
&dest,
&url,
&["/secret/**".to_string()],
&["/secret/public/**".to_string()],
None,
)
.unwrap();
assert!(dest.join("public/a.txt").exists(), "public present");
assert!(
dest.join("secret/public/s.txt").exists(),
"allowed nested path must be re-included"
);
assert!(
!dest.join("secret/private/p.txt").exists(),
"denied nested path must stay excluded"
);
}
#[test]
fn three_level_alternating_nesting_respects_specificity() {
// deny /secret, allow /secret/public, deny /secret/public/admin.
// The deepest deny must win even though a shallower allow re-includes
// its parent: order patterns by depth, not all-excludes-then-includes.
let (td, url) = bare_remote(&[
("public/a.txt", b"pub\n"),
("secret/private/p.txt", b"PRIV\n"),
("secret/public/s.txt", b"SHARED\n"),
("secret/public/admin/k.txt", b"ADMIN\n"),
]);
let dest = td.path().join("dest");
setup_partial_clone(
&dest,
&url,
&[
"/secret/**".to_string(),
"/secret/public/admin/**".to_string(),
],
&["/secret/public/**".to_string()],
None,
)
.unwrap();
assert!(dest.join("public/a.txt").exists(), "public present");
assert!(
dest.join("secret/public/s.txt").exists(),
"allowed middle path must be re-included"
);
assert!(
!dest.join("secret/private/p.txt").exists(),
"denied sibling must stay excluded"
);
assert!(
!dest.join("secret/public/admin/k.txt").exists(),
"deepest denied path must stay excluded despite the shallower re-include"
);
}
#[test]
fn exact_path_glob_is_excluded() {
// A wildcard-free glob must exclude the exact file, not just a subtree.
let (td, url) = bare_remote(&[("public/a.txt", b"pub\n"), ("docs/private", b"SECRET\n")]);
let dest = td.path().join("dest");
setup_partial_clone(&dest, &url, &["/docs/private".to_string()], &[], None).unwrap();
assert!(dest.join("public/a.txt").exists(), "public present");
assert!(
!dest.join("docs/private").exists(),
"exact-path withheld file must be excluded"
);
}
#[test]
fn sparse_patterns_subtree_and_exact() {
assert_eq!(sparse_patterns("/secret/**"), vec!["/secret/".to_string()]);
assert_eq!(
sparse_patterns("/docs/private"),
vec!["/docs/private".to_string(), "/docs/private/".to_string()]
);
}
#[test]
fn withheld_response_requires_both_fields() {
let ok: WithheldPathsResponse =
serde_json::from_str(r#"{"withheld":["/secret/**"],"reinclude":[]}"#).unwrap();
assert_eq!(ok.withheld, vec!["/secret/**".to_string()]);
assert!(ok.reinclude.is_empty());
// A missing field is a schema mismatch: it must error, not default to [].
assert!(serde_json::from_str::<WithheldPathsResponse>(r#"{"withheld":[]}"#).is_err());
// A wrong-typed field must error too.
assert!(serde_json::from_str::<WithheldPathsResponse>(
r#"{"withheld":"nope","reinclude":[]}"#
)
.is_err());
}
#[test]
fn parse_repo_accepts_url_and_bare() {
let (url, o, n) = parse_repo("gitlawb://did:key:zAbc/myrepo").unwrap();
assert_eq!(url, "gitlawb://did:key:zAbc/myrepo");
assert_eq!((o.as_str(), n.as_str()), ("did:key:zAbc", "myrepo"));
let (url2, o2, n2) = parse_repo("did:key:zAbc/myrepo").unwrap();
assert_eq!(url2, "gitlawb://did:key:zAbc/myrepo");
assert_eq!((o2.as_str(), n2.as_str()), ("did:key:zAbc", "myrepo"));
}
#[test]
fn parse_repo_rejects_malformed() {
assert!(parse_repo("noslash").is_err());
assert!(parse_repo("gitlawb://owner/").is_err());
assert!(parse_repo("/name").is_err());
// An extra slash would otherwise smuggle a path segment into the name.
assert!(parse_repo("owner/name/extra").is_err());
}
}
+12 -52
View File
@@ -35,36 +35,17 @@ struct Check {
fix: Option<String>,
}
enum CheckState {
Ok,
Warn,
Fail,
}
enum CheckState { Ok, Warn, Fail }
impl Check {
fn pass(label: &'static str, detail: impl Into<String>) -> Self {
Self {
label,
state: CheckState::Ok,
detail: detail.into(),
fix: None,
}
Self { label, state: CheckState::Ok, detail: detail.into(), fix: None }
}
fn warn(label: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
Self {
label,
state: CheckState::Warn,
detail: detail.into(),
fix: Some(fix.into()),
}
Self { label, state: CheckState::Warn, detail: detail.into(), fix: Some(fix.into()) }
}
fn fail(label: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
Self {
label,
state: CheckState::Fail,
detail: detail.into(),
fix: Some(fix.into()),
}
Self { label, state: CheckState::Fail, detail: detail.into(), fix: Some(fix.into()) }
}
}
@@ -73,9 +54,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!();
let dir = args.dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
});
let mut checks = Vec::new();
@@ -118,10 +97,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
{
Some(v) => {
let node = v["node"].as_str().unwrap_or("unknown");
checks.push(Check::pass(
"registration",
format!("registered with {node}"),
));
checks.push(Check::pass("registration", format!("registered with {node}")));
}
None => {
checks.push(Check::fail(
@@ -147,9 +123,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => {
checks.push(Check::fail(
"GITLAWB_NODE",
format!(
"set to local address ({v}) — git push/clone will fail against remote nodes"
),
format!("set to local address ({v}) — git push/clone will fail against remote nodes"),
"export GITLAWB_NODE=https://node.gitlawb.com",
));
}
@@ -230,7 +204,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
// ── Render ────────────────────────────────────────────────────────────
for check in &checks {
let icon = match check.state {
CheckState::Ok => "✓",
CheckState::Ok => "✓",
CheckState::Warn => "⚠",
CheckState::Fail => "✗",
};
@@ -242,9 +216,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
println!();
let has_issues = checks
.iter()
.any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
let has_issues = checks.iter().any(|c| matches!(c.state, CheckState::Fail | CheckState::Warn));
if !has_issues {
println!("Everything looks good. Run `gl quickstart` to create your first repo.");
} else {
@@ -273,10 +245,7 @@ pub async fn run(args: DoctorArgs) -> Result<()> {
fn which_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
dir.join(name).exists()
|| (cfg!(target_os = "windows") && dir.join(format!("{name}.exe")).exists())
})
std::env::split_paths(&paths).any(|dir| dir.join(name).exists())
})
.unwrap_or(false)
}
@@ -308,12 +277,7 @@ async fn check_version(current: &'static str) -> Check {
let tag = match body["tag_name"].as_str() {
Some(t) => t.trim_start_matches('v'),
None => {
return Check::pass(
"version",
format!("v{current} (could not parse latest tag)"),
)
}
None => return Check::pass("version", format!("v{current} (could not parse latest tag)")),
};
if is_newer(tag, current) {
@@ -331,11 +295,7 @@ async fn check_version(current: &'static str) -> Check {
fn is_newer(latest: &str, current: &str) -> bool {
fn parse(v: &str) -> (u32, u32, u32) {
let mut parts = v.splitn(3, '.').map(|p| p.parse::<u32>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
(parts.next().unwrap_or(0), parts.next().unwrap_or(0), parts.next().unwrap_or(0))
}
parse(latest) > parse(current)
}
+6 -35
View File
@@ -14,49 +14,22 @@ impl NodeClient {
pub fn new(node_url: impl Into<String>, keypair: Option<Keypair>) -> Self {
let inner = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent(format!("gl/{} gitlawb-cli", env!("CARGO_PKG_VERSION")))
.user_agent("gl/0.2.0 gitlawb-cli")
.build()
.expect("failed to build HTTP client");
Self {
inner,
node_url: node_url.into(),
keypair,
}
Self { inner, node_url: node_url.into(), keypair }
}
/// GET request — no auth (public read endpoints).
pub async fn get(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
self.inner
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))
}
/// GET with RFC 9421 HTTP Signature auth, for owner-only read endpoints.
/// Signs over the empty body (same shape the node verifies for signed reads).
pub async fn get_signed(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let kp = self
.keypair
.as_ref()
.context("get_signed requires an identity keypair")?;
let signed = sign_request(kp, "GET", path, b"");
let req = self
.inner
.get(&url)
.header("Content-Digest", signed.content_digest)
.header("Signature-Input", signed.signature_input)
.header("Signature", signed.signature);
req.send().await.with_context(|| format!("GET {url}"))
self.inner.get(&url).send().await.with_context(|| format!("GET {url}"))
}
/// POST with JSON body + RFC 9421 HTTP Signature auth.
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
let mut req = self.inner
.post(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
@@ -75,8 +48,7 @@ impl NodeClient {
/// PUT with RFC 9421 HTTP Signature auth (idempotent write).
pub async fn put(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
let mut req = self.inner
.put(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
@@ -95,8 +67,7 @@ impl NodeClient {
/// DELETE with RFC 9421 HTTP Signature auth.
pub async fn delete(&self, path: &str, body: &[u8]) -> Result<reqwest::Response> {
let url = format!("{}{}", self.node_url, path);
let mut req = self
.inner
let mut req = self.inner
.delete(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
+29 -100
View File
@@ -1,7 +1,7 @@
use anyhow::{Context, Result};
use clap::Subcommand;
use gitlawb_core::did::DidDocument;
use gitlawb_core::identity::Keypair;
use gitlawb_core::did::DidDocument;
use std::fs;
use std::path::{Path, PathBuf};
@@ -90,12 +90,8 @@ pub fn load_keypair_from_dir(dir: Option<&std::path::Path>) -> Result<Keypair> {
.join(".gitlawb")
};
let path = key_path(&base);
let pem = fs::read_to_string(&path).with_context(|| {
format!(
"no identity found at {}\nRun `gl identity new` to create one",
path.display()
)
})?;
let pem = fs::read_to_string(&path)
.with_context(|| format!("no identity found at {}\nRun `gl identity new` to create one", path.display()))?;
Keypair::from_pem(&pem).context("failed to load keypair from PEM")
}
@@ -186,21 +182,13 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
let base = gitlawb_dir(dir)?;
let src = key_path(&base);
let pem = fs::read_to_string(&src).with_context(|| {
format!(
"no identity found at {} — run `gl identity new` first",
src.display()
)
})?;
let pem = fs::read_to_string(&src)
.with_context(|| format!("no identity found at {} — run `gl identity new` first", src.display()))?;
// Verify it loads before copying
let keypair = Keypair::from_pem(&pem).context("identity.pem is corrupted")?;
let dest = out.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_default()
.join("identity.pem.bak")
});
let dest = out.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("identity.pem.bak"));
#[cfg(unix)]
{
@@ -291,24 +279,18 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_creates_pem() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
assert!(dir.path().join("identity.pem").exists());
}
#[tokio::test]
async fn test_cmd_new_force_overwrites_on_confirm() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
// Simulate user typing "y" at the --force prompt
let mut reader = std::io::Cursor::new(b"y\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader)
.await
.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader).await.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_ne!(pem1, pem2);
}
@@ -316,15 +298,11 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_force_aborts_on_n() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
// Simulate user typing "n" — should abort even with --force
let mut reader = std::io::Cursor::new(b"n\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader)
.await
.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), true, &mut reader).await.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_eq!(pem1, pem2);
}
@@ -332,14 +310,10 @@ mod tests {
#[tokio::test]
async fn test_cmd_new_no_force_aborts_on_n() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
let pem1 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
let mut reader = std::io::Cursor::new(b"n\n");
cmd_new_with_reader(Some(dir.path().to_path_buf()), false, &mut reader)
.await
.unwrap();
cmd_new_with_reader(Some(dir.path().to_path_buf()), false, &mut reader).await.unwrap();
let pem2 = std::fs::read_to_string(dir.path().join("identity.pem")).unwrap();
assert_eq!(pem1, pem2);
}
@@ -347,30 +321,22 @@ mod tests {
#[tokio::test]
async fn test_cmd_show_succeeds() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_show(Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
async fn test_cmd_export_produces_did_document() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_export(Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
async fn test_cmd_sign_succeeds() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_sign("hello gitlawb".to_string(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
cmd_sign("hello gitlawb".to_string(), Some(dir.path().to_path_buf())).await.unwrap();
}
#[test]
@@ -385,9 +351,7 @@ mod tests {
#[tokio::test]
async fn test_pem_roundtrip() {
let dir = TempDir::new().unwrap();
cmd_new(Some(dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dir.path().to_path_buf()), false).await.unwrap();
// Loading the keypair back should succeed and produce a valid DID
let kp = load_keypair_from_dir(Some(dir.path())).unwrap();
let did = kp.did().to_string();
@@ -400,21 +364,12 @@ mod tests {
let dst_dir = TempDir::new().unwrap();
// Create an identity and back it up
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
let backup_path = src_dir.path().join("identity.pem.bak");
cmd_backup(
Some(backup_path.clone()),
Some(src_dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_backup(Some(backup_path.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
// Restore to a fresh directory
cmd_restore(backup_path, Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_restore(backup_path, Some(dst_dir.path().to_path_buf()), false).await.unwrap();
// The restored DID should match the original
let orig = load_keypair_from_dir(Some(src_dir.path())).unwrap();
@@ -448,28 +403,15 @@ mod tests {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false).await.unwrap();
let backup = src_dir.path().join("identity.pem.bak");
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf()))
.await
.unwrap();
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
// Simulate user typing "y" at the --force prompt
let mut reader = std::io::Cursor::new(b"y\n");
cmd_restore_with_reader(
backup,
Some(dst_dir.path().to_path_buf()),
true,
&mut reader,
)
.await
.unwrap();
cmd_restore_with_reader(backup, Some(dst_dir.path().to_path_buf()), true, &mut reader).await.unwrap();
let src_kp = load_keypair_from_dir(Some(src_dir.path())).unwrap();
let dst_kp = load_keypair_from_dir(Some(dst_dir.path())).unwrap();
@@ -481,29 +423,16 @@ mod tests {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false)
.await
.unwrap();
cmd_new(Some(src_dir.path().to_path_buf()), false).await.unwrap();
cmd_new(Some(dst_dir.path().to_path_buf()), false).await.unwrap();
let original_did = load_keypair_from_dir(Some(dst_dir.path())).unwrap().did();
let backup = src_dir.path().join("identity.pem.bak");
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf()))
.await
.unwrap();
cmd_backup(Some(backup.clone()), Some(src_dir.path().to_path_buf())).await.unwrap();
// Simulate user typing "n" — should abort
let mut reader = std::io::Cursor::new(b"n\n");
cmd_restore_with_reader(
backup,
Some(dst_dir.path().to_path_buf()),
true,
&mut reader,
)
.await
.unwrap();
cmd_restore_with_reader(backup, Some(dst_dir.path().to_path_buf()), true, &mut reader).await.unwrap();
let dst_kp = load_keypair_from_dir(Some(dst_dir.path())).unwrap();
assert_eq!(original_did, dst_kp.did());
+18 -48
View File
@@ -73,9 +73,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
"did": did.to_string(),
"capabilities": ["git:push", "git:fetch", "issue:create", "pr:open"],
}))?;
let resp = client
.post("/api/register", &body)
.await
let resp = client.post("/api/register", &body).await
.context("failed to connect to node")?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON from register")?;
@@ -91,10 +89,9 @@ pub async fn run(args: InitArgs) -> Result<()> {
// Save UCAN if returned
if let Some(ucan) = payload.get("ucan").and_then(|v| v.as_str()) {
if !ucan.is_empty() {
let ucan_dir = args
.dir
.clone()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
let ucan_dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir().unwrap_or_default().join(".gitlawb")
});
std::fs::create_dir_all(&ucan_dir)?;
let record = json!({
"ucan": ucan,
@@ -102,10 +99,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
"did": did.to_string(),
"saved_at": chrono::Utc::now().to_rfc3339(),
});
std::fs::write(
ucan_dir.join("ucan.json"),
serde_json::to_string_pretty(&record)?,
)?;
std::fs::write(ucan_dir.join("ucan.json"), serde_json::to_string_pretty(&record)?)?;
}
}
println!(" Agent registered.");
@@ -124,9 +118,7 @@ pub async fn run(args: InitArgs) -> Result<()> {
"description": args.description,
"is_public": true,
}))?;
let resp = client
.post("/api/v1/repos", &body)
.await
let resp = client.post("/api/v1/repos", &body).await
.context("failed to create repo")?;
let repo_status = resp.status();
let repo_result: Value = resp.json().await.context("invalid JSON from create repo")?;
@@ -207,11 +199,7 @@ mod tests {
fn write_identity(dir: &TempDir) -> gitlawb_core::identity::Keypair {
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
kp
}
@@ -242,45 +230,30 @@ mod tests {
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Welcome","ucan":"test.token","trust_score":0.5}"#)
.create_async()
.await;
.create_async().await;
let _repo = server
.mock("POST", "/api/v1/repos")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"r1","name":"test-repo"}"#)
.create_async()
.await;
.create_async().await;
// We can't fully test gl init because it uses std::env::current_dir()
// but we can test the individual steps
let client = NodeClient::new(server.url(), Some(kp.clone()));
let client = NodeClient::new(&server.url(), Some(kp.clone()));
// Register
let body = serde_json::to_vec(&json!({
"did": kp.did().to_string(),
"capabilities": ["git:push"],
}))
.unwrap();
let resp: Value = client
.post("/api/register", &body)
.await
.unwrap()
.json()
.await
.unwrap();
})).unwrap();
let resp: Value = client.post("/api/register", &body).await.unwrap().json().await.unwrap();
assert_eq!(resp["message"], "Welcome");
// Create repo
let body = serde_json::to_vec(&json!({"name": "test-repo", "is_public": true})).unwrap();
let resp: Value = client
.post("/api/v1/repos", &body)
.await
.unwrap()
.json()
.await
.unwrap();
let resp: Value = client.post("/api/v1/repos", &body).await.unwrap().json().await.unwrap();
assert_eq!(resp["name"], "test-repo");
}
@@ -295,15 +268,13 @@ mod tests {
.with_status(409)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"already registered"}"#)
.create_async()
.await;
.create_async().await;
let client = NodeClient::new(server.url(), Some(kp.clone()));
let client = NodeClient::new(&server.url(), Some(kp.clone()));
let body = serde_json::to_vec(&json!({
"did": kp.did().to_string(),
"capabilities": ["git:push"],
}))
.unwrap();
})).unwrap();
let resp = client.post("/api/register", &body).await.unwrap();
let status = resp.status();
let payload: Value = resp.json().await.unwrap();
@@ -324,10 +295,9 @@ mod tests {
.with_status(409)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"repository already exists"}"#)
.create_async()
.await;
.create_async().await;
let client = NodeClient::new(server.url(), Some(kp.clone()));
let client = NodeClient::new(&server.url(), Some(kp.clone()));
let body = serde_json::to_vec(&json!({"name": "existing", "is_public": true})).unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
let status = resp.status();
+2 -8
View File
@@ -63,11 +63,7 @@ async fn cmd_list(node: String) -> Result<()> {
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
// Trim pinned_at to date+time without subseconds
let ts = if pinned_at.len() >= 19 {
&pinned_at[..19]
} else {
pinned_at
};
let ts = if pinned_at.len() >= 19 { &pinned_at[..19] } else { pinned_at };
println!(" {cid}");
println!(" sha256: {sha}");
println!(" pinned: {ts}");
@@ -102,9 +98,7 @@ async fn cmd_get(cid: String, node: String) -> Result<()> {
// Write raw bytes to stdout (allows piping to files or other tools)
let bytes = resp.bytes().await.context("failed to read response body")?;
use std::io::Write;
std::io::stdout()
.write_all(&bytes)
.context("failed to write to stdout")?;
std::io::stdout().write_all(&bytes).context("failed to write to stdout")?;
Ok(())
}
+40 -142
View File
@@ -1,10 +1,10 @@
//! `gl issue` — issue management commands.
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;
use chrono::Utc;
use uuid::Uuid;
use crate::http::NodeClient;
@@ -88,48 +88,19 @@ pub enum IssueCmd {
pub async fn run(args: IssueArgs) -> Result<()> {
match args.cmd {
IssueCmd::Create {
repo,
title,
body,
node,
dir,
} => cmd_create(repo, title, body, node, dir).await,
IssueCmd::Create { repo, title, body, node, dir } => {
cmd_create(repo, title, body, node, dir).await
}
IssueCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
IssueCmd::Show {
repo,
id,
node,
dir,
} => cmd_show(repo, id, node, dir).await,
IssueCmd::Close {
repo,
id,
node,
dir,
} => cmd_close(repo, id, node, dir).await,
IssueCmd::Comment {
repo,
id,
body,
node,
dir,
} => cmd_issue_comment(repo, id, body, node, dir).await,
IssueCmd::Comments {
repo,
id,
node,
dir,
} => cmd_issue_comments(repo, id, node, dir).await,
IssueCmd::Show { repo, id, node, dir } => cmd_show(repo, id, node, dir).await,
IssueCmd::Close { repo, id, node, dir } => cmd_close(repo, id, node, dir).await,
IssueCmd::Comment { repo, id, body, node, dir } => cmd_issue_comment(repo, id, body, node, dir).await,
IssueCmd::Comments { repo, id, node, dir } => cmd_issue_comments(repo, id, node, dir).await,
}
}
/// Resolve "repo" into (owner, name) using the caller's keypair DID when no slash given.
async fn resolve_repo(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
async fn resolve_repo(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
Ok((owner.to_string(), name.to_string()))
} else {
@@ -138,11 +109,7 @@ async fn resolve_repo(
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node info missing 'did'")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -190,9 +157,7 @@ async fn cmd_create(
let client = NodeClient::new(&node, Some(keypair));
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp = client
.post(&path, &request_body)
.await
let resp = client.post(&path, &request_body).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -217,11 +182,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let resp: Value = client
.get(&path)
.await?
.json()
.await
let resp: Value = client.get(&path).await?.json().await
.context("failed to list issues")?;
let issues = resp["issues"].as_array().cloned().unwrap_or_default();
@@ -237,15 +198,8 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let id = issue["id"].as_str().unwrap_or("?");
let title = issue["title"].as_str().unwrap_or("(no title)");
let status = issue["status"].as_str().unwrap_or("?");
let created = issue["created_at"]
.as_str()
.map(|s| &s[..10])
.unwrap_or("?");
let icon = match status {
"open" => "○",
"closed" => "✗",
_ => "?",
};
let created = issue["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
let icon = match status { "open" => "○", "closed" => "✗", _ => "?" };
println!(" {icon} {id:.8} {created} {title}");
}
Ok(())
@@ -256,10 +210,7 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option<PathBuf>)
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
let resp = client
.get(&path)
.await
.context("failed to connect to node")?;
let resp = client.get(&path).await.context("failed to connect to node")?;
let status = resp.status();
let issue: Value = resp.json().await.context("invalid JSON response")?;
@@ -293,9 +244,7 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
let body = serde_json::to_vec(&json!({ "status": "closed" }))?;
let path = format!("/api/v1/repos/{owner}/{name}/issues/{id}");
let resp = client
.post(&format!("{path}/close"), &body)
.await
let resp = client.post(&format!("{path}/close"), &body).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -310,23 +259,15 @@ async fn cmd_close(repo: String, id: String, node: String, dir: Option<PathBuf>)
}
async fn cmd_issue_comment(
repo: String,
id: String,
body: String,
node: String,
dir: Option<PathBuf>,
repo: String, id: String, body: String,
node: String, dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let payload = serde_json::to_vec(&serde_json::json!({ "body": body }))?;
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments"),
&payload,
)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments"), &payload).await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -340,23 +281,12 @@ async fn cmd_issue_comment(
Ok(())
}
async fn cmd_issue_comments(
repo: String,
id: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_issue_comments(repo: String, id: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
let resp: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{name}/issues/{id}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{name}/issues/{id}/comments")).await?
.json().await.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -367,11 +297,7 @@ async fn cmd_issue_comments(
println!("Comments on issue {id} ({} total)\n", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -409,13 +335,9 @@ mod tests {
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -435,13 +357,9 @@ mod tests {
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -540,9 +458,7 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/missing-id$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/missing-id$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -571,9 +487,7 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/abc-123/close$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/abc-123/close$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -601,9 +515,7 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/close$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/close$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -659,9 +571,7 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/comments$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/bad-id/comments$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -690,9 +600,7 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/issues/abc123/comments$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues/abc123/comments$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -700,14 +608,9 @@ mod tests {
.create_async()
.await;
cmd_issue_comments(
"myrepo".to_string(),
"abc123".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_issue_comments("myrepo".to_string(), "abc123".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -727,13 +630,8 @@ mod tests {
.create_async()
.await;
cmd_issue_comments(
"myrepo".to_string(),
"abc123".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_issue_comments("myrepo".to_string(), "abc123".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
}
+1 -16
View File
@@ -7,21 +7,19 @@ mod agent;
mod bounty;
mod cert;
mod changelog;
mod clone;
mod doctor;
mod http;
mod name;
mod identity;
mod init;
mod ipfs_cmd;
mod issue;
mod mcp;
mod mirror;
mod name;
mod node;
mod node_stake;
mod peer;
mod pr;
mod profile;
mod protect;
mod quickstart;
mod register;
@@ -31,7 +29,6 @@ mod status;
mod sync;
mod task;
mod ucan_cmd;
mod visibility;
mod webhook;
mod whoami;
@@ -58,9 +55,6 @@ enum Commands {
/// Register this agent with a gitlawb node
Register(register::RegisterArgs),
/// Clone a gitlawb repo, handling private subtrees cleanly
Clone(clone::CloneArgs),
/// Manage repositories
Repo(repo::RepoArgs),
@@ -118,15 +112,9 @@ enum Commands {
/// List and inspect registered agents on a node
Agent(agent::AgentArgs),
/// Manage your agent profile (name, bio, avatar, social links)
Profile(profile::ProfileArgs),
/// Manage branch protection rules
Protect(protect::ProtectArgs),
/// Manage path-scoped read visibility rules
Visibility(visibility::VisibilityArgs),
/// Show unified activity changelog for a repository
Changelog(changelog::ChangelogArgs),
@@ -154,7 +142,6 @@ async fn main() -> Result<()> {
match cli.command {
Commands::Identity { cmd } => identity::run(cmd).await,
Commands::Register(args) => register::run(args).await,
Commands::Clone(args) => clone::run(args).await,
Commands::Repo(args) => repo::run(args).await,
Commands::Issue(args) => issue::run(args).await,
Commands::Pr(args) => pr::run(args).await,
@@ -174,9 +161,7 @@ async fn main() -> Result<()> {
Commands::Star(args) => star::run(args).await,
Commands::Status(args) => status::run(args).await,
Commands::Agent(args) => agent::run(args).await,
Commands::Profile(args) => profile::run(args).await,
Commands::Protect(args) => protect::run(args).await,
Commands::Visibility(args) => visibility::run(args).await,
Commands::Changelog(args) => changelog::run(args).await,
Commands::Bounty(args) => bounty::run(args).await,
Commands::Ucan(args) => ucan_cmd::run(args).await,
+124 -322
View File
@@ -89,10 +89,7 @@ async fn serve(node: String, dir: Option<PathBuf>) -> Result<()> {
}
};
tracing::debug!(
"← {}",
msg.get("method").and_then(|v| v.as_str()).unwrap_or("?")
);
tracing::debug!("← {}", msg.get("method").and_then(|v| v.as_str()).unwrap_or("?"));
let response = handle(&msg, &node, dir.as_deref()).await;
@@ -107,7 +104,10 @@ async fn serve(node: String, dir: Option<PathBuf>) -> Result<()> {
async fn handle(msg: &Value, node: &str, dir: Option<&std::path::Path>) -> Value {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(|v| v.as_str()).unwrap_or("");
let method = msg
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let result = dispatch(method, params, node, dir).await;
@@ -688,10 +688,8 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let repo: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{name}")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&repo)?)
}
@@ -699,10 +697,8 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let commits: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/commits"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{name}/commits")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&commits)?)
}
@@ -711,10 +707,8 @@ async fn call_tool(
let path = args["path"].as_str().unwrap_or("");
let owner = resolve_owner(&args, &client).await?;
let tree: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/tree/{path}"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{name}/tree/{path}")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&tree)?)
}
@@ -741,21 +735,12 @@ async fn call_tool(
}
"agent_capabilities" => Ok(serde_json::to_string_pretty(&json!([
"git:push",
"git:fetch",
"git:admin",
"pr:open",
"pr:merge",
"pr:review",
"issue:create",
"issue:close",
"network:join",
"network:gossip",
"agent:deploy",
"agent:invoke",
"repo:admin",
"repo:read",
"repo:write"
"git:push", "git:fetch", "git:admin",
"pr:open", "pr:merge", "pr:review",
"issue:create", "issue:close",
"network:join", "network:gossip",
"agent:deploy", "agent:invoke",
"repo:admin", "repo:read", "repo:write"
]))?),
"ucan_show" => {
@@ -789,10 +774,7 @@ async fn call_tool(
let name = args["name"].as_str().context("missing 'name'")?;
let owner = resolve_owner(&args, &client).await?;
let resp = client
.get(&format!(
"/{owner}/{name}/info/refs?service=git-upload-pack"
))
.await?;
.get(&format!("/{owner}/{name}/info/refs?service=git-upload-pack")).await?;
let bytes = resp.bytes().await?;
// Parse pkt-line refs
let refs = parse_info_refs(&bytes);
@@ -800,9 +782,7 @@ async fn call_tool(
}
"pr_create" => {
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let head = args["head"].as_str().context("missing 'head'")?;
let base = args["base"].as_str().unwrap_or("main");
@@ -815,10 +795,8 @@ async fn call_tool(
"target_branch": base,
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &body)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -826,10 +804,8 @@ async fn call_tool(
let repo = args["repo"].as_str().context("missing 'repo'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -838,20 +814,12 @@ async fn call_tool(
let number = args["number"].as_i64().context("missing 'number'")?;
let owner = resolve_owner(&args, &client).await?;
let pr: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}")).await?
.json().await?;
let reviews: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?
.json()
.await?;
Ok(serde_json::to_string_pretty(
&json!({ "pr": pr, "reviews": reviews["reviews"] }),
)?)
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&json!({ "pr": pr, "reviews": reviews["reviews"] }))?)
}
"pr_diff" => {
@@ -859,18 +827,14 @@ async fn call_tool(
let number = args["number"].as_i64().context("missing 'number'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff")).await?
.json().await?;
let diff = resp["diff"].as_str().unwrap_or("(empty diff)");
Ok(diff.to_string())
}
"pr_review" => {
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let number = args["number"].as_i64().context("missing 'number'")?;
let status = args["status"].as_str().context("missing 'status'")?;
@@ -880,13 +844,8 @@ async fn call_tool(
"body": args["body"],
}))?;
let resp: Value = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"),
&body,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -896,24 +855,16 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp: Value = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"),
&body,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"webhook_create" => {
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let url = args["url"].as_str().context("missing 'url'")?;
let events = args["events"]
.as_array()
let events = args["events"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_else(|| vec!["*"]);
let owner = resolve_owner(&args, &client).await?;
@@ -923,10 +874,8 @@ async fn call_tool(
"events": events,
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -934,10 +883,8 @@ async fn call_tool(
let repo = args["repo"].as_str().context("missing 'repo'")?;
let owner = resolve_owner(&args, &client).await?;
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{repo}/hooks")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -947,14 +894,13 @@ async fn call_tool(
let owner = resolve_owner(&args, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp: Value = client
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body)
.await?
.json()
.await?;
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
// ── Bounty tools ────────────────────────────────────────────────────
"bounty_list" => {
let url = if let Some(repo) = args.get("repo").and_then(|v| v.as_str()) {
let (owner, name) = repo.split_once('/').context("repo must be owner/name")?;
@@ -976,11 +922,7 @@ async fn call_tool(
"bounty_show" => {
let id = args["id"].as_str().context("missing 'id'")?;
let resp: Value = client
.get(&format!("/api/v1/bounties/{id}"))
.await?
.json()
.await?;
let resp: Value = client.get(&format!("/api/v1/bounties/{id}")).await?.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -994,13 +936,8 @@ async fn call_tool(
"tx_hash": args.get("tx_hash").and_then(|v| v.as_str()),
});
let resp: Value = client
.post(
&format!("/api/v1/repos/{owner}/{name}/bounties"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{name}/bounties"), &serde_json::to_vec(&body)?)
.await?.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1008,13 +945,8 @@ async fn call_tool(
let id = args["id"].as_str().context("missing 'id'")?;
let body = json!({ "wallet": args.get("wallet").and_then(|v| v.as_str()) });
let resp: Value = client
.post(
&format!("/api/v1/bounties/{id}/claim"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/bounties/{id}/claim"), &serde_json::to_vec(&body)?)
.await?.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1023,13 +955,8 @@ async fn call_tool(
let pr_id = args["pr_id"].as_str().context("missing 'pr_id'")?;
let body = json!({ "pr_id": pr_id });
let resp: Value = client
.post(
&format!("/api/v1/bounties/{id}/submit"),
&serde_json::to_vec(&body)?,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/bounties/{id}/submit"), &serde_json::to_vec(&body)?)
.await?.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1039,6 +966,7 @@ async fn call_tool(
}
// ── Task tools ────────────────────────────────────────────────────
"task_list" => {
let limit = args["limit"].as_i64().unwrap_or(50);
let mut path = format!("/api/v1/tasks?limit={limit}");
@@ -1075,10 +1003,8 @@ async fn call_tool(
let id = args["id"].as_str().context("missing 'id'")?;
let body = serde_json::to_vec(&json!({ "assignee_did": assignee_did }))?;
let resp: Value = client
.post(&format!("/api/v1/tasks/{id}/claim"), &body)
.await?
.json()
.await?;
.post(&format!("/api/v1/tasks/{id}/claim"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1091,14 +1017,13 @@ async fn call_tool(
"by_did": by_did,
}))?;
let resp: Value = client
.post(&format!("/api/v1/tasks/{id}/complete"), &body)
.await?
.json()
.await?;
.post(&format!("/api/v1/tasks/{id}/complete"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
// ── UCAN delegation tools ─────────────────────────────────────────
"ucan_delegate" => {
let kp = keypair.context("no identity found — run `gl identity new` first")?;
let to_str = args["to"].as_str().context("missing 'to'")?;
@@ -1109,8 +1034,7 @@ async fn call_tool(
.parse()
.map_err(|e: gitlawb_core::Error| anyhow::anyhow!("{e}"))?;
let exp = args
.get("expiry_hours")
let exp = args.get("expiry_hours")
.and_then(|v| v.as_i64())
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h));
@@ -1133,14 +1057,11 @@ async fn call_tool(
"ucan_verify" => {
let token = args["token"].as_str().context("missing 'token'")?;
let ucan =
gitlawb_core::ucan::Ucan::decode(token).context("failed to parse UCAN token")?;
let ucan = gitlawb_core::ucan::Ucan::decode(token)
.context("failed to parse UCAN token")?;
let sig_valid = ucan.verify_signature().is_ok();
let expired = ucan.is_expired();
let caps: Vec<Value> = ucan
.payload
.att
.iter()
let caps: Vec<Value> = ucan.payload.att.iter()
.map(|c| json!({ "with": c.with, "can": c.can }))
.collect();
@@ -1156,6 +1077,7 @@ async fn call_tool(
}
// ── Issue tools ───────────────────────────────────────────────────
"issue_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
@@ -1165,17 +1087,13 @@ async fn call_tool(
(owner, repo.to_string())
};
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/issues"))
.await?
.json()
.await?;
.get(&format!("/api/v1/repos/{owner}/{name}/issues")).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"issue_create" => {
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let title = args["title"].as_str().context("missing 'title'")?;
let (owner, name) = if let Some((o, n)) = repo.split_once('/') {
@@ -1189,17 +1107,13 @@ async fn call_tool(
"body": args.get("body").and_then(|v| v.as_str()),
}))?;
let resp: Value = client
.post(&format!("/api/v1/repos/{owner}/{name}/issues"), &body)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{name}/issues"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
"issue_comment" => {
keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
keypair.as_ref().context("no identity found — run `gl identity new` first")?;
let repo = args["repo"].as_str().context("missing 'repo'")?;
let issue_id = args["issue_id"].as_str().context("missing 'issue_id'")?;
let comment_body = args["body"].as_str().context("missing 'body'")?;
@@ -1211,13 +1125,8 @@ async fn call_tool(
};
let body = serde_json::to_vec(&json!({ "body": comment_body }))?;
let resp: Value = client
.post(
&format!("/api/v1/repos/{owner}/{name}/issues/{issue_id}/comments"),
&body,
)
.await?
.json()
.await?;
.post(&format!("/api/v1/repos/{owner}/{name}/issues/{issue_id}/comments"), &body).await?
.json().await?;
Ok(serde_json::to_string_pretty(&resp)?)
}
@@ -1257,19 +1166,10 @@ fn parse_info_refs(bytes: &[u8]) -> Value {
}
while pos + 4 <= bytes.len() {
let Ok(hex) = std::str::from_utf8(&bytes[pos..pos + 4]) else {
break;
};
let Ok(len) = usize::from_str_radix(hex, 16) else {
break;
};
if len == 0 {
pos += 4;
continue;
}
if len < 4 || pos + len > bytes.len() {
break;
}
let Ok(hex) = std::str::from_utf8(&bytes[pos..pos + 4]) else { break };
let Ok(len) = usize::from_str_radix(hex, 16) else { break };
if len == 0 { pos += 4; continue; }
if len < 4 || pos + len > bytes.len() { break; }
let line = std::str::from_utf8(&bytes[pos + 4..pos + len]).unwrap_or("");
let line = line.trim_end_matches('\n');
@@ -1356,11 +1256,7 @@ mod tests {
assert!(output.contains("\r\n\r\n"));
// Verify the content-length value matches the JSON body
let parts: Vec<&str> = output.splitn(2, "\r\n\r\n").collect();
let len: usize = parts[0]
.strip_prefix("Content-Length: ")
.unwrap()
.parse()
.unwrap();
let len: usize = parts[0].strip_prefix("Content-Length: ").unwrap().parse().unwrap();
assert_eq!(len, parts[1].len());
}
@@ -1405,14 +1301,9 @@ mod tests {
#[tokio::test]
async fn test_dispatch_notifications_initialized() {
let result = dispatch(
"notifications/initialized",
json!({}),
"http://localhost",
None,
)
.await
.unwrap();
let result = dispatch("notifications/initialized", json!({}), "http://localhost", None)
.await
.unwrap();
assert_eq!(result, Value::Null);
}
@@ -1432,7 +1323,10 @@ mod tests {
assert!(!tools.is_empty());
// Verify expected tools exist
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
let tool_names: Vec<&str> = tools
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(tool_names.contains(&"identity_show"));
assert!(tool_names.contains(&"repo_create"));
assert!(tool_names.contains(&"pr_create"));
@@ -1496,14 +1390,8 @@ mod tests {
let tools = tools.as_array().unwrap();
for tool in tools {
assert!(tool.get("name").is_some(), "tool missing name: {tool}");
assert!(
tool.get("description").is_some(),
"tool missing description: {tool}"
);
assert!(
tool.get("inputSchema").is_some(),
"tool missing inputSchema: {tool}"
);
assert!(tool.get("description").is_some(), "tool missing description: {tool}");
assert!(tool.get("inputSchema").is_some(), "tool missing inputSchema: {tool}");
assert_eq!(tool["inputSchema"]["type"], "object");
}
}
@@ -1521,12 +1409,8 @@ mod tests {
#[test]
fn test_tool_definitions_include_task_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(names.contains(&"task_list"));
assert!(names.contains(&"task_create"));
assert!(names.contains(&"task_claim"));
@@ -1536,12 +1420,8 @@ mod tests {
#[test]
fn test_tool_definitions_include_ucan_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(names.contains(&"ucan_delegate"));
assert!(names.contains(&"ucan_verify"));
}
@@ -1549,12 +1429,8 @@ mod tests {
#[test]
fn test_tool_definitions_include_issue_tools() {
let tools = tool_definitions();
let names: Vec<&str> = tools
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
let names: Vec<&str> = tools.as_array().unwrap()
.iter().filter_map(|t| t["name"].as_str()).collect();
assert!(names.contains(&"issue_list"));
assert!(names.contains(&"issue_create"));
assert!(names.contains(&"issue_comment"));
@@ -1564,24 +1440,14 @@ mod tests {
async fn test_task_list_via_mcp() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"/api/v1/tasks\?".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tasks":[{"id":"t1","kind":"test","status":"pending"}]}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"task_list",
json!({"status": "pending"}),
&server.url(),
None,
)
.await
.unwrap();
let result = call_tool("task_list", json!({"status": "pending"}), &server.url(), None)
.await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["tasks"][0]["id"], "t1");
}
@@ -1591,28 +1457,21 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/tasks")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t2","kind":"code-review","status":"pending"}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"task_create",
json!({"kind": "code-review"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["kind"], "code-review");
}
@@ -1622,28 +1481,18 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/t3/claim")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t3","status":"claimed"}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"task_claim",
json!({"id": "t3"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
"task_claim", json!({"id": "t3"}), &server.url(), Some(dir.path()),
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["status"], "claimed");
}
@@ -1653,28 +1502,19 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/tasks/t4/complete")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"t4","status":"completed"}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"task_complete",
json!({"id": "t4", "result": "all good"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
"task_complete", json!({"id": "t4", "result": "all good"}),
&server.url(), Some(dir.path()),
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["status"], "completed");
}
@@ -1683,11 +1523,7 @@ mod tests {
async fn test_ucan_delegate_via_mcp() {
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let audience = gitlawb_core::identity::Keypair::generate();
let result = call_tool(
@@ -1700,9 +1536,7 @@ mod tests {
}),
"http://localhost",
Some(dir.path()),
)
.await
.unwrap();
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["audience"], audience.did().to_string());
@@ -1715,25 +1549,15 @@ mod tests {
let kp = gitlawb_core::identity::Keypair::generate();
let audience = gitlawb_core::identity::Keypair::generate();
let ucan = gitlawb_core::ucan::Ucan::issue(
&kp,
audience.did(),
vec![gitlawb_core::ucan::Capability::new(
"gitlawb://test",
"git/push",
)],
&kp, audience.did(),
vec![gitlawb_core::ucan::Capability::new("gitlawb://test", "git/push")],
None,
)
.unwrap();
).unwrap();
let token = ucan.encode().unwrap();
let result = call_tool(
"ucan_verify",
json!({"token": token}),
"http://localhost",
None,
)
.await
.unwrap();
"ucan_verify", json!({"token": token}), "http://localhost", None,
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["valid"], true);
assert_eq!(parsed["signature_valid"], true);
@@ -1748,17 +1572,11 @@ mod tests {
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"issues":[{"id":"i1","title":"Bug","status":"open"}]}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"issue_list",
json!({"repo": "alice/myrepo"}),
&server.url(),
None,
)
.await
.unwrap();
"issue_list", json!({"repo": "alice/myrepo"}), &server.url(), None,
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["issues"][0]["id"], "i1");
}
@@ -1768,28 +1586,20 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/repos/alice/myrepo/issues")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"i2","title":"New bug"}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"issue_create",
json!({"repo": "alice/myrepo", "title": "New bug", "body": "steps to reproduce"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
&server.url(), Some(dir.path()),
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["title"], "New bug");
}
@@ -1799,28 +1609,20 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/repos/alice/myrepo/issues/i1/comments")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"id":"c1","body":"looks good"}"#)
.create_async()
.await;
.create_async().await;
let result = call_tool(
"issue_comment",
json!({"repo": "alice/myrepo", "issue_id": "i1", "body": "looks good"}),
&server.url(),
Some(dir.path()),
)
.await
.unwrap();
&server.url(), Some(dir.path()),
).await.unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["body"], "looks good");
}
+7 -23
View File
@@ -43,16 +43,12 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
// ── 1. Derive repo name ───────────────────────────────────────────────
let name = match args.repo {
Some(n) => n,
None => extract_repo_name(&source).with_context(|| {
format!("could not derive repo name from '{source}' — use --repo <name>")
})?,
None => extract_repo_name(&source)
.with_context(|| format!("could not derive repo name from '{source}' — use --repo <name>"))?,
};
// Validate: same rules as `gl repo create`
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
bail!("repo name '{name}' must contain only alphanumeric characters, hyphens, and underscores\nUse --repo to provide a valid name");
}
@@ -72,11 +68,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> {
let mirror_path = tmp_root.join(&name);
// RAII guard: removes the temp dir when this binding is dropped (success or failure).
struct TmpGuard(PathBuf);
impl Drop for TmpGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl Drop for TmpGuard { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } }
let _guard = TmpGuard(tmp_root);
println!("Cloning source (this may take a while for large repos)...");
@@ -226,11 +218,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("POST", "/api/v1/repos")
@@ -244,9 +232,7 @@ mod tests {
// API error path by calling the create step directly via NodeClient.
let kp2 = gitlawb_core::identity::Keypair::generate();
let client = NodeClient::new(server.url(), Some(kp2));
let body =
serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"}))
.unwrap();
let body = serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"})).unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
assert_eq!(resp.status(), 409);
let payload: Value = resp.json().await.unwrap();
@@ -267,9 +253,7 @@ mod tests {
let kp = gitlawb_core::identity::Keypair::generate();
let client = NodeClient::new(server.url(), Some(kp));
let body =
serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"}))
.unwrap();
let body = serde_json::to_vec(&json!({"name":"myrepo","is_public":true,"default_branch":"main"})).unwrap();
let resp = client.post("/api/v1/repos", &body).await.unwrap();
assert!(resp.status().is_success());
let payload: Value = resp.json().await.unwrap();
+34 -59
View File
@@ -9,8 +9,11 @@
//! gl name resolve-did <did> — resolve DID from on-chain registry
use alloy::{
network::EthereumWallet, primitives::Address, providers::ProviderBuilder,
signers::local::PrivateKeySigner, sol,
network::EthereumWallet,
primitives::Address,
providers::ProviderBuilder,
signers::local::PrivateKeySigner,
sol,
};
use anyhow::{Context, Result};
use clap::Subcommand;
@@ -130,39 +133,18 @@ pub enum NameCmd {
pub async fn run(args: NameArgs) -> Result<()> {
match args.cmd {
NameCmd::Register {
name,
private_key,
rpc_url,
contract,
dir,
} => cmd_register(name, private_key, rpc_url, contract, dir).await,
NameCmd::Resolve {
name,
rpc_url,
contract,
} => cmd_resolve(name, rpc_url, contract).await,
NameCmd::Lookup {
did,
rpc_url,
contract,
} => cmd_lookup(did, rpc_url, contract).await,
NameCmd::Available {
name,
rpc_url,
contract,
} => cmd_available(name, rpc_url, contract).await,
NameCmd::RegisterDid {
private_key,
rpc_url,
contract,
dir,
} => cmd_register_did(private_key, rpc_url, contract, dir).await,
NameCmd::ResolveDid {
did,
rpc_url,
contract,
} => cmd_resolve_did(did, rpc_url, contract).await,
NameCmd::Register { name, private_key, rpc_url, contract, dir } =>
cmd_register(name, private_key, rpc_url, contract, dir).await,
NameCmd::Resolve { name, rpc_url, contract } =>
cmd_resolve(name, rpc_url, contract).await,
NameCmd::Lookup { did, rpc_url, contract } =>
cmd_lookup(did, rpc_url, contract).await,
NameCmd::Available { name, rpc_url, contract } =>
cmd_available(name, rpc_url, contract).await,
NameCmd::RegisterDid { private_key, rpc_url, contract, dir } =>
cmd_register_did(private_key, rpc_url, contract, dir).await,
NameCmd::ResolveDid { did, rpc_url, contract } =>
cmd_resolve_did(did, rpc_url, contract).await,
}
}
@@ -170,35 +152,25 @@ pub async fn run(args: NameArgs) -> Result<()> {
fn identity_dir(dir: Option<PathBuf>) -> PathBuf {
dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
})
}
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let keypair =
gitlawb_core::identity::Keypair::from_pem(&pem).context("Failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let keypair = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("Failed to parse identity PEM")?;
Ok(keypair.did().to_string())
}
fn load_did_and_document(dir: Option<PathBuf>) -> Result<(String, String)> {
let path = identity_dir(dir).join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let keypair =
gitlawb_core::identity::Keypair::from_pem(&pem).context("Failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let keypair = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("Failed to parse identity PEM")?;
let did = keypair.did();
let vk = keypair.verifying_key();
let doc = gitlawb_core::did::DidDocument::new(did.clone(), &vk);
@@ -218,18 +190,21 @@ fn build_write_provider(
private_key: &str,
rpc_url: &str,
) -> Result<impl alloy::providers::Provider + Clone> {
let signer: PrivateKeySigner = private_key
.trim()
let signer: PrivateKeySigner = private_key.trim()
.parse()
.context("Invalid Ethereum private key (expected 0x-prefixed hex)")?;
let wallet = EthereumWallet::from(signer);
let url: reqwest::Url = rpc_url.parse().context("Invalid RPC URL")?;
let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
let url: reqwest::Url = rpc_url.parse()
.context("Invalid RPC URL")?;
let provider = ProviderBuilder::new()
.wallet(wallet)
.connect_http(url);
Ok(provider)
}
fn build_read_provider(rpc_url: &str) -> Result<impl alloy::providers::Provider + Clone> {
let url: reqwest::Url = rpc_url.parse().context("Invalid RPC URL")?;
let url: reqwest::Url = rpc_url.parse()
.context("Invalid RPC URL")?;
Ok(ProviderBuilder::new().connect_http(url))
}
+33 -84
View File
@@ -35,6 +35,7 @@ pub enum NodeCmd {
},
// ── On-chain PoS ──────────────────────────────────────────────────────
/// Stake $GITLAWB and register this node on-chain (Base L2)
Register {
/// Amount of $GITLAWB to stake (whole tokens, e.g. 10000)
@@ -124,47 +125,18 @@ pub async fn run(args: NodeArgs) -> Result<()> {
NodeCmd::Status { node } => cmd_status(node).await,
NodeCmd::Trust { did, node } => cmd_trust(did, node).await,
NodeCmd::Resolve { did, node } => cmd_resolve(did, node).await,
NodeCmd::Register {
stake,
http_url,
private_key,
token,
contract,
rpc_url,
dir,
} => {
node_stake::cmd_register(stake, http_url, private_key, token, contract, rpc_url, dir)
.await
}
NodeCmd::Heartbeat {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_heartbeat(private_key, contract, rpc_url, dir).await,
NodeCmd::OnchainStatus {
contract,
rpc_url,
dir,
} => node_stake::cmd_onchain_status(contract, rpc_url, dir).await,
NodeCmd::Claim {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_claim(private_key, contract, rpc_url, dir).await,
NodeCmd::UnstakeRequest {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_unstake_request(private_key, contract, rpc_url, dir).await,
NodeCmd::Unstake {
private_key,
contract,
rpc_url,
dir,
} => node_stake::cmd_unstake(private_key, contract, rpc_url, dir).await,
NodeCmd::Register { stake, http_url, private_key, token, contract, rpc_url, dir } =>
node_stake::cmd_register(stake, http_url, private_key, token, contract, rpc_url, dir).await,
NodeCmd::Heartbeat { private_key, contract, rpc_url, dir } =>
node_stake::cmd_heartbeat(private_key, contract, rpc_url, dir).await,
NodeCmd::OnchainStatus { contract, rpc_url, dir } =>
node_stake::cmd_onchain_status(contract, rpc_url, dir).await,
NodeCmd::Claim { private_key, contract, rpc_url, dir } =>
node_stake::cmd_claim(private_key, contract, rpc_url, dir).await,
NodeCmd::UnstakeRequest { private_key, contract, rpc_url, dir } =>
node_stake::cmd_unstake_request(private_key, contract, rpc_url, dir).await,
NodeCmd::Unstake { private_key, contract, rpc_url, dir } =>
node_stake::cmd_unstake(private_key, contract, rpc_url, dir).await,
}
}
@@ -181,13 +153,9 @@ async fn cmd_status(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
// ── Fetch node info (required — bail if unreachable) ──────────────────
let info_resp = client
.get("/")
.await
let info_resp = client.get("/").await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?;
let info: Value = info_resp
.json()
.await
let info: Value = info_resp.json().await
.map_err(|e| anyhow::anyhow!("Invalid JSON from node: {e}"))?;
let did = info["did"].as_str().unwrap_or("unknown");
@@ -221,18 +189,10 @@ async fn cmd_status(node: String) -> Result<()> {
println!("Network");
if let Some(ref peers) = peers_val {
let count = peers["count"].as_u64().unwrap_or_else(|| {
peers["peers"]
.as_array()
.map(|a| a.len() as u64)
.unwrap_or(0)
peers["peers"].as_array().map(|a| a.len() as u64).unwrap_or(0)
});
let reachable = peers["peers"]
.as_array()
.map(|a| {
a.iter()
.filter(|p| p["reachable"].as_bool().unwrap_or(false))
.count()
})
let reachable = peers["peers"].as_array()
.map(|a| a.iter().filter(|p| p["reachable"].as_bool().unwrap_or(false)).count())
.unwrap_or(0);
println!(" Peers: {count} known ({reachable} reachable)");
} else {
@@ -244,7 +204,9 @@ async fn cmd_status(node: String) -> Result<()> {
let peer_id = p2p["peer_id"].as_str().unwrap_or("unknown");
println!(" P2P: enabled — peer_id: {peer_id}");
if let Some(topics) = p2p["topics"].as_array() {
let topic_list: Vec<&str> = topics.iter().filter_map(|t| t.as_str()).collect();
let topic_list: Vec<&str> = topics.iter()
.filter_map(|t| t.as_str())
.collect();
if !topic_list.is_empty() {
println!(" Topics: {}", topic_list.join(", "));
}
@@ -283,7 +245,8 @@ async fn cmd_status(node: String) -> Result<()> {
if let Some(ref events) = events_val {
println!("Activity (recent ref-updates)");
// Events may be a top-level array or wrapped in an "events" key
let items: Option<&Vec<Value>> = events.as_array().or_else(|| events["events"].as_array());
let items: Option<&Vec<Value>> = events.as_array()
.or_else(|| events["events"].as_array());
if let Some(arr) = items {
if arr.is_empty() {
@@ -292,8 +255,7 @@ async fn cmd_status(node: String) -> Result<()> {
for ev in arr.iter().take(5) {
let repo = ev["repo"].as_str().unwrap_or("?");
let ref_name = ev["ref"].as_str().unwrap_or("?");
let ts = ev["timestamp"]
.as_str()
let ts = ev["timestamp"].as_str()
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {ts} {repo} {ref_name}");
@@ -308,9 +270,9 @@ async fn cmd_status(node: String) -> Result<()> {
// Pins
println!("Pins");
if let Some(ref pins) = pins_val {
let count = pins["count"]
.as_u64()
.unwrap_or_else(|| pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0));
let count = pins["count"].as_u64().unwrap_or_else(|| {
pins["pins"].as_array().map(|a| a.len() as u64).unwrap_or(0)
});
println!(" Pinned CIDs: {count}");
} else {
println!(" IPFS not configured");
@@ -323,9 +285,7 @@ async fn cmd_status(node: String) -> Result<()> {
async fn cmd_trust(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/agents/{did}/trust");
let resp = client
.get(&path)
.await
let resp = client.get(&path).await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?;
if !resp.status().is_success() {
@@ -333,9 +293,7 @@ async fn cmd_trust(did: String, node: String) -> Result<()> {
anyhow::bail!("trust query failed ({status}) for {did}");
}
let trust: Value = resp
.json()
.await
let trust: Value = resp.json().await
.map_err(|e| anyhow::anyhow!("Invalid JSON response: {e}"))?;
let score = trust["trust_score"].as_f64().unwrap_or(0.0);
@@ -352,12 +310,9 @@ async fn cmd_trust(did: String, node: String) -> Result<()> {
async fn cmd_resolve(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let info: Value = client
.get("/")
.await
let info: Value = client.get("/").await
.map_err(|e| anyhow::anyhow!("Cannot reach node at {node}: {e}"))?
.json()
.await
.json().await
.map_err(|e| anyhow::anyhow!("Invalid JSON from node: {e}"))?;
let node_did = info["did"].as_str().unwrap_or("unknown");
@@ -367,14 +322,8 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
println!("DID resolution for {did}");
println!(" DID: {node_did}");
println!(" Node URL: {node}");
println!(
" Version: {}",
info["version"].as_str().unwrap_or("unknown")
);
println!(
" Network: {}",
info["network"].as_str().unwrap_or("unknown")
);
println!(" Version: {}", info["version"].as_str().unwrap_or("unknown"));
println!(" Network: {}", info["network"].as_str().unwrap_or("unknown"));
if let Some(peer_id) = info["p2p_peer_id"].as_str() {
println!(" P2P ID: {peer_id}");
}
+22 -85
View File
@@ -89,11 +89,7 @@ pub async fn cmd_register(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
// 1. Check balance
let bal = token_c
.balanceOf(operator_addr)
.call()
.await
.context("balanceOf failed")?;
let bal = token_c.balanceOf(operator_addr).call().await.context("balanceOf failed")?;
if bal < stake_wei {
anyhow::bail!(
"insufficient $GITLAWB balance: have {}, need {}",
@@ -115,14 +111,8 @@ pub async fn cmd_register(
.send()
.await
.context("approve failed")?;
let approve_receipt = approve_tx
.get_receipt()
.await
.context("approve receipt failed")?;
println!(
" approved: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", approve_receipt.transaction_hash))
);
let approve_receipt = approve_tx.get_receipt().await.context("approve receipt failed")?;
println!(" approved: {}", explorer_tx_url(&rpc_url, &format!("{:?}", approve_receipt.transaction_hash)));
}
// 3. Register
@@ -132,17 +122,11 @@ pub async fn cmd_register(
.send()
.await
.context("registerNode failed")?;
let receipt = register_tx
.get_receipt()
.await
.context("registerNode receipt failed")?;
let receipt = register_tx.get_receipt().await.context("registerNode receipt failed")?;
println!();
println!("✓ Node registered");
println!(
" Tx: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!(" Tx: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(" Operator wallet: {operator_addr}");
println!();
println!("Next: set these env vars on the node:");
@@ -170,16 +154,9 @@ pub async fn cmd_heartbeat(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Posting heartbeat for {did}...");
let tx = staking
.heartbeat(did_hash)
.send()
.await
.context("heartbeat failed")?;
let tx = staking.heartbeat(did_hash).send().await.context("heartbeat failed")?;
let receipt = tx.get_receipt().await.context("heartbeat receipt failed")?;
println!(
"✓ heartbeat sent: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!("✓ heartbeat sent: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
Ok(())
}
@@ -196,11 +173,7 @@ pub async fn cmd_onchain_status(
let provider = ProviderBuilder::new().connect_http(url);
let staking = GitlawbNodeStaking::new(contract_addr, provider);
let info = staking
.getNodeInfo(did_hash)
.call()
.await
.context("getNodeInfo failed")?;
let info = staking.getNodeInfo(did_hash).call().await.context("getNodeInfo failed")?;
println!("On-chain status for {did}");
println!();
@@ -217,15 +190,9 @@ pub async fn cmd_onchain_status(
println!(" Registered: {} (unix)", info.registeredAt);
println!(" Active flag: {}", info.active);
println!(" Currently active: {}", info.currentlyActive);
println!(
" Pending rewards: {} $GITLAWB",
wei_to_tokens(info.pendingRewards)
);
println!(" Pending rewards: {} $GITLAWB", wei_to_tokens(info.pendingRewards));
if info.unstakeRequestAt > U256::ZERO {
println!(
" Unstake pending: yes (requested at unix {})",
info.unstakeRequestAt
);
println!(" Unstake pending: yes (requested at unix {})", info.unstakeRequestAt);
}
Ok(())
@@ -248,16 +215,9 @@ pub async fn cmd_claim(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Claiming rewards for {did}...");
let tx = staking
.claimRewards(did_hash)
.send()
.await
.context("claimRewards failed")?;
let tx = staking.claimRewards(did_hash).send().await.context("claimRewards failed")?;
let receipt = tx.get_receipt().await.context("claim receipt failed")?;
println!(
"✓ rewards claimed: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!("✓ rewards claimed: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
Ok(())
}
@@ -278,19 +238,9 @@ pub async fn cmd_unstake_request(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Requesting unstake (starts 7-day cooldown)...");
let tx = staking
.requestUnstake(did_hash)
.send()
.await
.context("requestUnstake failed")?;
let receipt = tx
.get_receipt()
.await
.context("requestUnstake receipt failed")?;
println!(
"✓ unstake requested: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
let tx = staking.requestUnstake(did_hash).send().await.context("requestUnstake failed")?;
let receipt = tx.get_receipt().await.context("requestUnstake receipt failed")?;
println!("✓ unstake requested: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
println!(" Run `gl node unstake` after 7 days to complete withdrawal.");
Ok(())
}
@@ -312,16 +262,9 @@ pub async fn cmd_unstake(
let staking = GitlawbNodeStaking::new(contract_addr, provider);
println!("Completing unstake...");
let tx = staking
.unstake(did_hash)
.send()
.await
.context("unstake failed")?;
let tx = staking.unstake(did_hash).send().await.context("unstake failed")?;
let receipt = tx.get_receipt().await.context("unstake receipt failed")?;
println!(
"✓ unstaked: {}",
explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash))
);
println!("✓ unstaked: {}", explorer_tx_url(&rpc_url, &format!("{:?}", receipt.transaction_hash)));
Ok(())
}
@@ -329,19 +272,13 @@ pub async fn cmd_unstake(
fn load_did(dir: Option<PathBuf>) -> Result<String> {
let base = dir.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
});
let path = base.join("identity.pem");
let pem = std::fs::read_to_string(&path).with_context(|| {
format!(
"No identity at {} — run `gl identity new` first",
path.display()
)
})?;
let kp =
gitlawb_core::identity::Keypair::from_pem(&pem).context("failed to parse identity PEM")?;
let pem = std::fs::read_to_string(&path)
.with_context(|| format!("No identity at {} — run `gl identity new` first", path.display()))?;
let kp = gitlawb_core::identity::Keypair::from_pem(&pem)
.context("failed to parse identity PEM")?;
Ok(kp.did().to_string())
}
+9 -39
View File
@@ -51,11 +51,7 @@ pub enum PeerCmd {
pub async fn run(args: PeerArgs) -> Result<()> {
match args.cmd {
PeerCmd::List { node } => cmd_list(node).await,
PeerCmd::Add {
peer_url,
node,
dir,
} => cmd_add(peer_url, node, dir).await,
PeerCmd::Add { peer_url, node, dir } => cmd_add(peer_url, node, dir).await,
PeerCmd::Ping { did, node } => cmd_ping(did, node).await,
PeerCmd::Resolve { did, node } => cmd_resolve(did, node).await,
}
@@ -63,11 +59,7 @@ pub async fn run(args: PeerArgs) -> Result<()> {
async fn cmd_list(node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let resp: Value = client
.get("/api/v1/peers")
.await?
.json()
.await
let resp: Value = client.get("/api/v1/peers").await?.json().await
.context("failed to list peers")?;
let peers = resp["peers"].as_array().cloned().unwrap_or_default();
@@ -84,10 +76,7 @@ async fn cmd_list(node: String) -> Result<()> {
let did = peer["did"].as_str().unwrap_or("?");
let url = peer["http_url"].as_str().unwrap_or("?");
let reachable = peer["reachable"].as_bool().unwrap_or(false);
let last_seen = peer["last_seen"]
.as_str()
.map(|s| &s[..10])
.unwrap_or("never");
let last_seen = peer["last_seen"].as_str().map(|s| &s[..10]).unwrap_or("never");
let status = if reachable { "✓" } else { "✗" };
println!(" {status} {url}");
println!(" did: {did}");
@@ -103,14 +92,9 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
// Fetch our node's public URL so we can announce it to the peer
let local_client = NodeClient::new(&node, None);
let node_info: Value = local_client
.get("/")
.await?
.json()
.await
let node_info: Value = local_client.get("/").await?.json().await
.context("failed to fetch local node info")?;
let my_url = node_info["public_url"]
.as_str()
let my_url = node_info["public_url"].as_str()
.unwrap_or(&node)
.to_string();
@@ -122,9 +106,7 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
let remote_client = NodeClient::new(&peer_url, Some(keypair));
let announce_path = "/api/v1/peers/announce";
let resp = remote_client
.post(announce_path, &body)
.await
let resp = remote_client.post(announce_path, &body).await
.context("failed to connect to peer")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -161,20 +143,12 @@ async fn cmd_add(peer_url: String, node: String, dir: Option<PathBuf>) -> Result
async fn cmd_ping(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/peers/{did}/ping");
let resp: Value = client
.get(&path)
.await?
.json()
.await
let resp: Value = client.get(&path).await?.json().await
.context("failed to ping peer")?;
let url = resp["http_url"].as_str().unwrap_or("?");
let reachable = resp["reachable"].as_bool().unwrap_or(false);
let status = if reachable {
"reachable"
} else {
"unreachable"
};
let status = if reachable { "reachable" } else { "unreachable" };
println!("Peer: {did}");
println!(" URL: {url}");
@@ -186,11 +160,7 @@ async fn cmd_resolve(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let encoded = urlencoding::encode(&did);
let path = format!("/api/v1/resolve/{encoded}");
let resp: Value = client
.get(&path)
.await?
.json()
.await
let resp: Value = client.get(&path).await?.json().await
.context("failed to resolve DID")?;
let source = resp["source"].as_str().unwrap_or("not found");
+64 -231
View File
@@ -128,56 +128,18 @@ pub enum PrCmd {
pub async fn run(args: PrArgs) -> Result<()> {
match args.cmd {
PrCmd::Create {
repo,
head,
base,
title,
body,
owner,
node,
dir,
} => cmd_create(repo, head, base, title, body, owner, node, dir).await,
PrCmd::Create { repo, head, base, title, body, owner, node, dir } =>
cmd_create(repo, head, base, title, body, owner, node, dir).await,
PrCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
PrCmd::View {
repo,
number,
node,
dir,
} => cmd_view(repo, number, node, dir).await,
PrCmd::Diff {
repo,
number,
node,
dir,
} => cmd_diff(repo, number, node, dir).await,
PrCmd::Merge {
repo,
number,
node,
dir,
} => cmd_merge(repo, number, node, dir).await,
PrCmd::Review {
repo,
number,
status,
body,
node,
dir,
} => cmd_review(repo, number, status, body, node, dir).await,
PrCmd::Comment {
repo,
number,
body,
node,
dir,
} => cmd_comment(repo, number, body, node, dir).await,
PrCmd::Comments {
repo,
number,
node,
dir,
} => cmd_comments(repo, number, node, dir).await,
PrCmd::View { repo, number, node, dir } => cmd_view(repo, number, node, dir).await,
PrCmd::Diff { repo, number, node, dir } => cmd_diff(repo, number, node, dir).await,
PrCmd::Merge { repo, number, node, dir } => cmd_merge(repo, number, node, dir).await,
PrCmd::Review { repo, number, status, body, node, dir } =>
cmd_review(repo, number, status, body, node, dir).await,
PrCmd::Comment { repo, number, body, node, dir } =>
cmd_comment(repo, number, body, node, dir).await,
PrCmd::Comments { repo, number, node, dir } =>
cmd_comments(repo, number, node, dir).await,
}
}
@@ -194,9 +156,7 @@ fn detect_remote_owner() -> Option<String> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
if !out.status.success() { return None; }
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
@@ -206,14 +166,8 @@ fn detect_remote_owner() -> Option<String> {
#[allow(clippy::too_many_arguments)]
async fn cmd_create(
repo: String,
head: String,
base: String,
title: String,
body: Option<String>,
owner_override: Option<String>,
node: String,
dir: Option<PathBuf>,
repo: String, head: String, base: String, title: String,
body: Option<String>, owner_override: Option<String>, node: String, dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
// Priority: --owner flag > git remote origin > caller's own DID
@@ -229,9 +183,7 @@ async fn cmd_create(
"target_branch": base,
}))?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls"), &payload).await
.context("failed to connect to node")?;
let status = resp.status();
let pr: Value = resp.json().await.context("invalid JSON")?;
@@ -254,12 +206,8 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls"))
.await?
.json()
.await
.context("invalid JSON")?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls")).await?
.json().await.context("invalid JSON")?;
let prs = resp["pulls"].as_array().cloned().unwrap_or_default();
if prs.is_empty() {
@@ -275,17 +223,8 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
let source = pr["source_branch"].as_str().unwrap_or("?");
let target = pr["target_branch"].as_str().unwrap_or("?");
let author = pr["author_did"].as_str().unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let status_icon = match status {
"open" => "○",
"merged" => "✓",
"closed" => "✗",
_ => "?",
};
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let status_icon = match status { "open" => "○", "merged" => "✓", "closed" => "✗", _ => "?" };
println!(" {status_icon} #{number} {title}");
println!(" {source} → {target} by {author_short}");
println!();
@@ -298,12 +237,8 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let pr: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}"))
.await?
.json()
.await
.context("invalid JSON")?;
let pr: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}")).await?
.json().await.context("invalid JSON")?;
let title = pr["title"].as_str().unwrap_or("?");
let status = pr["status"].as_str().unwrap_or("?");
@@ -321,57 +256,31 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
}
// Show reviews
let reviews: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"
))
.await?
.json()
.await
.context("invalid JSON")?;
let reviews: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews")).await?
.json().await.context("invalid JSON")?;
let reviews = reviews["reviews"].as_array().cloned().unwrap_or_default();
if !reviews.is_empty() {
println!("\nReviews ({}):", reviews.len());
for r in &reviews {
let reviewer = r["reviewer_did"].as_str().unwrap_or("?");
let reviewer_short = reviewer
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let reviewer_short = reviewer.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let rstatus = r["status"].as_str().unwrap_or("?");
let rbody = r["body"].as_str().unwrap_or("");
let icon = match rstatus {
"approved" => "✓",
"changes_requested" => "✗",
_ => "·",
};
let icon = match rstatus { "approved" => "✓", "changes_requested" => "✗", _ => "·" };
println!(" {icon} {reviewer_short}: {rstatus}");
if !rbody.is_empty() {
println!(" {rbody}");
}
if !rbody.is_empty() { println!(" {rbody}"); }
}
}
// Show comments
let comments: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let comments: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")).await?
.json().await.context("invalid JSON")?;
let comments = comments["comments"].as_array().cloned().unwrap_or_default();
if !comments.is_empty() {
println!("\nComments ({}):", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -386,12 +295,8 @@ async fn cmd_diff(repo: String, number: u64, node: String, dir: Option<PathBuf>)
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff"))
.await?
.json()
.await
.context("invalid JSON")?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/diff")).await?
.json().await.context("invalid JSON")?;
let diff = resp["diff"].as_str().unwrap_or("");
if diff.is_empty() {
@@ -408,12 +313,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&serde_json::json!({}))?;
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"),
&body,
)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge"), &body).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -430,12 +330,8 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
}
async fn cmd_review(
repo: String,
number: u64,
status: String,
body: Option<String>,
node: String,
dir: Option<PathBuf>,
repo: String, number: u64, status: String, body: Option<String>,
node: String, dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
@@ -446,12 +342,7 @@ async fn cmd_review(
"body": body,
}))?;
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"),
&payload,
)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews"), &payload).await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -461,33 +352,21 @@ async fn cmd_review(
anyhow::bail!("review failed ({code}): {msg}");
}
let icon = match status.as_str() {
"approved" => "✓",
"changes_requested" => "✗",
_ => "·",
};
let icon = match status.as_str() { "approved" => "✓", "changes_requested" => "✗", _ => "·" };
println!("{icon} Review submitted: {status} on PR #{number}");
Ok(())
}
async fn cmd_comment(
repo: String,
number: u64,
body: String,
node: String,
dir: Option<PathBuf>,
repo: String, number: u64, body: String,
node: String, dir: Option<PathBuf>,
) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, Some(keypair));
let payload = serde_json::to_vec(&serde_json::json!({ "body": body }))?;
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"),
&payload,
)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"), &payload).await
.context("failed to connect to node")?;
let code = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -506,14 +385,8 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
let owner = resolve_owner(&keypair);
let client = NodeClient::new(&node, None);
let resp: Value = client
.get(&format!(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/comments"
))
.await?
.json()
.await
.context("invalid JSON")?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")).await?
.json().await.context("invalid JSON")?;
let comments = resp["comments"].as_array().cloned().unwrap_or_default();
if comments.is_empty() {
@@ -524,11 +397,7 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
println!("Comments on PR #{number} ({} total)\n", comments.len());
for c in &comments {
let author = c["author_did"].as_str().unwrap_or("?");
let author_short = author
.split(':')
.next_back()
.map(|s| &s[..s.len().min(8)])
.unwrap_or("?");
let author_short = author.split(':').next_back().map(|s| &s[..s.len().min(8)]).unwrap_or("?");
let cbody = c["body"].as_str().unwrap_or("");
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
println!(" · {author_short} ({created})");
@@ -574,13 +443,9 @@ mod tests {
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -600,13 +465,9 @@ mod tests {
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -692,9 +553,7 @@ mod tests {
let _m2 = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/reviews$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/reviews$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -704,9 +563,7 @@ mod tests {
let _m3 = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -714,14 +571,9 @@ mod tests {
.create_async()
.await;
cmd_view(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_view("myrepo".to_string(), 1, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -761,9 +613,7 @@ mod tests {
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/99/comments$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/99/comments$".to_string()),
)
.with_status(404)
.with_header("content-type", "application/json")
@@ -792,9 +642,7 @@ mod tests {
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/pulls/1/comments$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -802,14 +650,9 @@ mod tests {
.create_async()
.await;
cmd_comments(
"myrepo".to_string(),
1,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_comments("myrepo".to_string(), 1, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -829,14 +672,9 @@ mod tests {
.create_async()
.await;
cmd_comments(
"myrepo".to_string(),
2,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_comments("myrepo".to_string(), 2, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -856,13 +694,8 @@ mod tests {
.create_async()
.await;
cmd_merge(
"myrepo".to_string(),
2,
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_merge("myrepo".to_string(), 2, server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
}
-510
View File
@@ -1,510 +0,0 @@
//! `gl profile` — manage your agent profile (display name, bio, avatar, socials).
//!
//! Profile metadata is stored on the gitlawb node and optionally pinned to IPFS
//! for decentralized resolution. All writes are signed with your DID key.
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::PathBuf;
use crate::http::NodeClient;
use crate::identity::load_keypair_from_dir;
#[derive(Args)]
pub struct ProfileArgs {
#[command(subcommand)]
pub cmd: ProfileCmd,
}
#[derive(Subcommand)]
pub enum ProfileCmd {
/// Set your profile metadata (display name, bio, avatar, social links)
Set {
/// Display name (e.g. "Axiom")
#[arg(long)]
name: Option<String>,
/// Short bio (max 280 characters)
#[arg(long)]
bio: Option<String>,
/// Avatar URL or IPFS CID (e.g. "ipfs://bafkrei..." or "https://...")
#[arg(long)]
avatar: Option<String>,
/// Website URL
#[arg(long)]
website: Option<String>,
/// Twitter/X handle (without @)
#[arg(long)]
twitter: Option<String>,
/// GitHub username
#[arg(long)]
github: Option<String>,
/// Farcaster username
#[arg(long)]
farcaster: Option<String>,
/// Telegram username
#[arg(long)]
telegram: Option<String>,
/// Pin profile JSON to IPFS for decentralized resolution
#[arg(long)]
pin: bool,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
#[arg(long)]
dir: Option<PathBuf>,
},
/// Show your own profile
Show {
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
#[arg(long)]
dir: Option<PathBuf>,
},
/// Get another agent's profile by DID
Get {
/// DID or short DID prefix of the agent
did: String,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
/// Import profile from a JSON file
Import {
/// Path to profile JSON file
path: PathBuf,
/// Pin profile JSON to IPFS
#[arg(long)]
pin: bool,
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
#[arg(long)]
dir: Option<PathBuf>,
},
/// Export your profile as JSON
Export {
/// Node URL
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
/// Identity directory
#[arg(long)]
dir: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfilePayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub socials: Option<Socials>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin_to_ipfs: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Socials {
#[serde(skip_serializing_if = "Option::is_none")]
pub twitter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub farcaster: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub telegram: Option<String>,
}
pub async fn run(args: ProfileArgs) -> Result<()> {
match args.cmd {
ProfileCmd::Set {
name,
bio,
avatar,
website,
twitter,
github,
farcaster,
telegram,
pin,
node,
dir,
} => {
cmd_set(
name, bio, avatar, website, twitter, github, farcaster, telegram, pin, node, dir,
)
.await
}
ProfileCmd::Show { node, dir } => cmd_show(node, dir).await,
ProfileCmd::Get { did, node } => cmd_get(did, node).await,
ProfileCmd::Import {
path,
pin,
node,
dir,
} => cmd_import(path, pin, node, dir).await,
ProfileCmd::Export { node, dir } => cmd_export(node, dir).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn cmd_set(
name: Option<String>,
bio: Option<String>,
avatar: Option<String>,
website: Option<String>,
twitter: Option<String>,
github: Option<String>,
farcaster: Option<String>,
telegram: Option<String>,
pin: bool,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
if name.is_none()
&& bio.is_none()
&& avatar.is_none()
&& website.is_none()
&& twitter.is_none()
&& github.is_none()
&& farcaster.is_none()
&& telegram.is_none()
{
anyhow::bail!(
"nothing to set — provide at least one of --name, --bio, --avatar, --website, --twitter, --github, --farcaster, --telegram"
);
}
if let Some(ref b) = bio {
if b.len() > 280 {
anyhow::bail!("bio must be 280 characters or fewer (got {})", b.len());
}
}
let keypair = load_keypair_from_dir(dir.as_deref())?;
let did = keypair.did();
let has_socials =
twitter.is_some() || github.is_some() || farcaster.is_some() || telegram.is_some();
let socials = if has_socials {
Some(Socials {
twitter,
github,
farcaster,
telegram,
})
} else {
None
};
let payload = ProfilePayload {
display_name: name,
bio,
avatar_url: avatar,
website,
socials,
pin_to_ipfs: if pin { Some(true) } else { None },
};
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&payload)?;
println!("Updating profile for {did}...");
let resp = client
.put("/api/v1/profile", &body)
.await
.context("failed to update profile")?;
let status = resp.status();
let resp_body: serde_json::Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = resp_body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("profile update failed ({status}): {msg}");
}
println!();
println!("✓ Profile updated");
if let Some(name) = resp_body.get("display_name").and_then(|v| v.as_str()) {
println!(" Name: {name}");
}
if let Some(bio) = resp_body.get("bio").and_then(|v| v.as_str()) {
println!(" Bio: {bio}");
}
if let Some(avatar) = resp_body.get("avatar_url").and_then(|v| v.as_str()) {
println!(" Avatar: {avatar}");
}
if let Some(cid) = resp_body.get("profile_cid").and_then(|v| v.as_str()) {
println!(" IPFS: ipfs://{cid}");
}
Ok(())
}
async fn cmd_show(node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let did_str = keypair.did().to_string();
let short = did_short(&did_str).to_string();
cmd_get(short, node).await
}
async fn cmd_get(did: String, node: String) -> Result<()> {
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/agents/{did}/profile");
let resp = client.get(&path).await.context("failed to fetch profile")?;
let status = resp.status();
if status.as_u16() == 404 {
println!("No profile found for {did}");
println!("Set one with: gl profile set --name \"Your Name\" --bio \"About you\"");
return Ok(());
}
let body: serde_json::Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("failed to get profile ({status}): {msg}");
}
let did_str = body["did"].as_str().unwrap_or(&did);
let name = body["display_name"].as_str().unwrap_or("(not set)");
let bio = body["bio"].as_str().unwrap_or("(not set)");
let avatar = body["avatar_url"].as_str();
let website = body["website"].as_str();
let cid = body["profile_cid"].as_str();
println!("Agent Profile");
println!(" DID: {did_str}");
println!(" Name: {name}");
println!(" Bio: {bio}");
if let Some(a) = avatar {
println!(" Avatar: {a}");
}
if let Some(w) = website {
println!(" Website: {w}");
}
if let Some(socials) = body.get("socials") {
let mut has_any = false;
if let Some(t) = socials["twitter"].as_str() {
if !has_any {
println!(" Socials:");
has_any = true;
}
println!(" Twitter: @{t}");
}
if let Some(g) = socials["github"].as_str() {
if !has_any {
println!(" Socials:");
has_any = true;
}
println!(" GitHub: {g}");
}
if let Some(f) = socials["farcaster"].as_str() {
if !has_any {
println!(" Socials:");
has_any = true;
}
println!(" Farcaster: {f}");
}
if let Some(tg) = socials["telegram"].as_str() {
if !has_any {
println!(" Socials:");
// suppress unused assignment warning
let _ = has_any;
}
println!(" Telegram: {tg}");
}
}
if let Some(c) = cid {
println!(" IPFS: ipfs://{c}");
}
let updated = body["updated_at"].as_str().unwrap_or("unknown");
println!(" Updated: {updated}");
Ok(())
}
async fn cmd_import(path: PathBuf, pin: bool, node: String, dir: Option<PathBuf>) -> Result<()> {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let mut payload: ProfilePayload =
serde_json::from_str(&content).context("invalid profile JSON")?;
if pin {
payload.pin_to_ipfs = Some(true);
}
if let Some(ref b) = payload.bio {
if b.len() > 280 {
anyhow::bail!("bio must be 280 characters or fewer (got {})", b.len());
}
}
let keypair = load_keypair_from_dir(dir.as_deref())?;
let did = keypair.did();
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&payload)?;
println!("Importing profile from {} for {did}...", path.display());
let resp = client
.put("/api/v1/profile", &body)
.await
.context("failed to import profile")?;
let status = resp.status();
let resp_body: serde_json::Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = resp_body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("profile import failed ({status}): {msg}");
}
println!("✓ Profile imported successfully");
Ok(())
}
async fn cmd_export(node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let did_str = keypair.did().to_string();
let short = did_short(&did_str);
let client = NodeClient::new(&node, None);
let path = format!("/api/v1/agents/{short}/profile");
let resp = client.get(&path).await.context("failed to fetch profile")?;
let status = resp.status();
if status.as_u16() == 404 {
anyhow::bail!("no profile found — set one first with `gl profile set`");
}
let body: serde_json::Value = resp.json().await.context("invalid JSON response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
anyhow::bail!("failed to export profile ({status}): {msg}");
}
let export = json!({
"display_name": body.get("display_name"),
"bio": body.get("bio"),
"avatar_url": body.get("avatar_url"),
"website": body.get("website"),
"socials": body.get("socials"),
});
println!("{}", serde_json::to_string_pretty(&export)?);
Ok(())
}
fn did_short(did: &str) -> &str {
did.rsplit(':').next().unwrap_or(did)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_did_short_extracts_suffix() {
assert_eq!(did_short("did:key:z6MkfP9F7z"), "z6MkfP9F7z");
assert_eq!(did_short("z6MkfP9F7z"), "z6MkfP9F7z");
}
#[test]
fn test_profile_payload_serialization() {
let payload = ProfilePayload {
display_name: Some("Axiom".to_string()),
bio: Some("AI builder".to_string()),
avatar_url: None,
website: None,
socials: Some(Socials {
twitter: Some("AxiomBot".to_string()),
github: Some("0xAxiom".to_string()),
farcaster: None,
telegram: None,
}),
pin_to_ipfs: None,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("Axiom"));
assert!(json.contains("AxiomBot"));
assert!(!json.contains("avatar_url"));
}
#[test]
fn test_profile_payload_deserialization() {
let json = r#"{
"display_name": "Test Agent",
"bio": "I test things",
"socials": {
"twitter": "testbot",
"github": "test-org"
}
}"#;
let payload: ProfilePayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.display_name.as_deref(), Some("Test Agent"));
assert_eq!(
payload.socials.as_ref().unwrap().twitter.as_deref(),
Some("testbot")
);
}
#[test]
fn test_bio_length_validation() {
let long_bio = "a".repeat(281);
assert!(long_bio.len() > 280);
}
}
+27 -117
View File
@@ -52,27 +52,13 @@ pub enum ProtectCmd {
pub async fn run(args: ProtectArgs) -> Result<()> {
match args.cmd {
ProtectCmd::Set {
branch,
repo,
node,
dir,
} => cmd_set(branch, repo, node, dir).await,
ProtectCmd::Remove {
branch,
repo,
node,
dir,
} => cmd_remove(branch, repo, node, dir).await,
ProtectCmd::Set { branch, repo, node, dir } => cmd_set(branch, repo, node, dir).await,
ProtectCmd::Remove { branch, repo, node, dir } => cmd_remove(branch, repo, node, dir).await,
ProtectCmd::List { repo, node, dir } => cmd_list(repo, node, dir).await,
}
}
async fn resolve_owner_repo(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
async fn resolve_owner_repo(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
if let Some((owner, name)) = repo.split_once('/') {
return Ok((owner.to_string(), name.to_string()));
}
@@ -82,11 +68,7 @@ async fn resolve_owner_repo(
did.split(':').next_back().unwrap_or(&did).to_string()
} else {
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
did.split(':').next_back().unwrap_or(did).to_string()
@@ -101,10 +83,7 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
let client = NodeClient::new(&node, Some(kp));
let resp = client
.post(
&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"),
b"",
)
.post(&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"), b"")
.await
.context("failed to connect to node")?;
@@ -121,22 +100,14 @@ async fn cmd_set(branch: String, repo: String, node: String, dir: Option<PathBuf
Ok(())
}
async fn cmd_remove(
branch: String,
repo: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_remove(branch: String, repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let (owner, name) = resolve_owner_repo(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(kp));
let resp = client
.delete(
&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"),
b"",
)
.delete(&format!("/api/v1/repos/{owner}/{name}/branches/{branch}/protect"), b"")
.await
.context("failed to connect to node")?;
@@ -169,18 +140,12 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
anyhow::bail!("list protected branches failed ({status}): {msg}");
}
let branches = body["protected_branches"]
.as_array()
.cloned()
.unwrap_or_default();
let branches = body["protected_branches"].as_array().cloned().unwrap_or_default();
if branches.is_empty() {
println!("No protected branches in {owner}/{name}");
} else {
println!(
"Protected branches in {owner}/{name} ({} total)\n",
branches.len()
);
println!("Protected branches in {owner}/{name} ({} total)\n", branches.len());
for b in &branches {
println!(" 🔒 {}", b.as_str().unwrap_or("?"));
}
@@ -197,19 +162,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/branches/main/protect".to_string(),
),
)
.mock("POST", mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/branches/main/protect".to_string()))
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"protected","repo":"z/myrepo","branch":"main"}"#)
@@ -221,9 +177,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -231,17 +185,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"POST",
mockito::Matcher::Regex(r"branches/main/protect".to_string()),
)
.mock("POST", mockito::Matcher::Regex(r"branches/main/protect".to_string()))
.with_status(400)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"only the repo owner can protect branches"}"#)
@@ -253,9 +200,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
).await.unwrap_err();
assert!(err.to_string().contains("protect failed"));
}
@@ -264,17 +209,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"DELETE",
mockito::Matcher::Regex(r"branches/main/protect".to_string()),
)
.mock("DELETE", mockito::Matcher::Regex(r"branches/main/protect".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"unprotected","branch":"main"}"#)
@@ -286,9 +224,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -296,30 +232,17 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"branches/protected".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":[],"count":0}"#)
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
@@ -327,39 +250,26 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"branches/protected".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"branches/protected".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"protected_branches":["main","release"],"count":2}"#)
.create_async()
.await;
cmd_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf())).await.unwrap();
}
#[test]
fn test_resolve_owner_repo_with_slash() {
// owner/repo format should split correctly
let rt = tokio::runtime::Runtime::new().unwrap();
let (owner, name) = rt
.block_on(resolve_owner_repo("alice/myrepo", "http://unused", None))
.unwrap();
let (owner, name) = rt.block_on(
resolve_owner_repo("alice/myrepo", "http://unused", None)
).unwrap();
assert_eq!(owner, "alice");
assert_eq!(name, "myrepo");
}
+6 -16
View File
@@ -40,9 +40,7 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
println!();
let dir = args.dir.clone().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitlawb")
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".gitlawb")
});
// ── Step 1: Identity ──────────────────────────────────────────────────
@@ -126,10 +124,7 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
}
Err(e) => {
println!(" ✗ Could not reach {}: {e}", args.node);
println!(
" You can retry later with: gl register --node {}",
args.node
);
println!(" You can retry later with: gl register --node {}", args.node);
println!();
// Non-fatal — continue
}
@@ -206,17 +201,11 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
let payload: Value = resp.json().await.unwrap_or_default();
let msg = payload["message"].as_str().unwrap_or("unknown");
println!(" ✗ Repo creation failed ({status}): {msg}");
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
);
println!(" Try manually: gl repo create {repo_name} --node {}", args.node);
}
Err(e) => {
println!(" ✗ Could not reach node: {e}");
println!(
" Try manually: gl repo create {repo_name} --node {}",
args.node
);
println!(" Try manually: gl repo create {repo_name} --node {}", args.node);
}
}
@@ -227,7 +216,8 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
// ── Helpers ───────────────────────────────────────────────────────────────
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
let keypair = gitlawb_core::identity::Keypair::generate();
let pem = keypair.to_pem()?;
+6 -9
View File
@@ -67,7 +67,10 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
}
// Save bootstrap UCAN
let ucan = payload.get("ucan").and_then(|v| v.as_str()).unwrap_or("");
let ucan = payload
.get("ucan")
.and_then(|v| v.as_str())
.unwrap_or("");
if !ucan.is_empty() {
let ucan_path = ucan_path(args.dir.as_deref())?;
@@ -81,10 +84,7 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
tracing::debug!("saved UCAN to {}", ucan_path.display());
}
let trust = payload
.get("trust_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let trust = payload.get("trust_score").and_then(|v| v.as_f64()).unwrap_or(0.0);
let expires = payload
.get("expires")
.and_then(|v| v.as_str())
@@ -183,10 +183,7 @@ mod tests {
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid signature"));
assert!(result.unwrap_err().to_string().contains("invalid signature"));
}
#[tokio::test]
+54 -328
View File
@@ -118,92 +118,24 @@ pub enum RepoCmd {
#[arg(long)]
json: bool,
},
/// Register this node as a replica of someone else's repo
ReplicaRegister {
/// Repository in owner/repo format
repo: String,
/// Publicly-reachable URL of YOUR node (the one hosting the replica)
#[arg(long)]
url: String,
/// URL of the origin node (where the repo lives)
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// Unregister this node as a replica
ReplicaUnregister {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
#[arg(long)]
dir: Option<PathBuf>,
},
/// List nodes currently mirroring a repo
Replicas {
/// Repository in owner/repo format
repo: String,
#[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")]
node: String,
},
}
pub async fn run(args: RepoArgs) -> Result<()> {
match args.cmd {
RepoCmd::Create {
name,
description,
private,
branch,
node,
dir,
} => cmd_create(name, description, !private, branch, node, dir).await,
RepoCmd::Create { name, description, private, branch, node, dir } => {
cmd_create(name, description, !private, branch, node, dir).await
}
RepoCmd::List { node, dir } => cmd_list(node, dir).await,
RepoCmd::Clone { name, node, dir } => cmd_clone(name, node, dir).await,
RepoCmd::Info { repo, node, dir } => cmd_info(repo, node, dir).await,
RepoCmd::Commits {
repo,
branch,
limit,
node,
dir,
} => cmd_commits(repo, branch, limit, node, dir).await,
RepoCmd::Fork {
repo,
name,
node,
dir,
} => cmd_fork(repo, name, node, dir).await,
RepoCmd::LabelAdd {
repo,
label,
node,
dir,
} => cmd_label_add(repo, label, node, dir).await,
RepoCmd::LabelRemove {
repo,
label,
node,
dir,
} => cmd_label_remove(repo, label, node, dir).await,
RepoCmd::LabelList { repo, node, dir } => cmd_label_list(repo, node, dir).await,
RepoCmd::Owner {
repo,
node,
dir,
json,
} => cmd_owner(repo, node, dir, json).await,
RepoCmd::ReplicaRegister {
repo,
url,
node,
dir,
} => cmd_replica_register(repo, url, node, dir).await,
RepoCmd::ReplicaUnregister { repo, node, dir } => {
cmd_replica_unregister(repo, node, dir).await
RepoCmd::Commits { repo, branch, limit, node, dir } => {
cmd_commits(repo, branch, limit, node, dir).await
}
RepoCmd::Replicas { repo, node } => cmd_replicas(repo, node).await,
RepoCmd::Fork { repo, name, node, dir } => cmd_fork(repo, name, node, dir).await,
RepoCmd::LabelAdd { repo, label, node, dir } => cmd_label_add(repo, label, node, dir).await,
RepoCmd::LabelRemove { repo, label, node, dir } => cmd_label_remove(repo, label, node, dir).await,
RepoCmd::LabelList { repo, node, dir } => cmd_label_list(repo, node, dir).await,
RepoCmd::Owner { repo, node, dir, json } => cmd_owner(repo, node, dir, json).await,
}
}
@@ -214,11 +146,7 @@ async fn resolve_owner_did(node: &str, dir: Option<&std::path::Path>) -> Result<
return Ok(did.split(':').next_back().unwrap_or(&did).to_string());
}
let client = NodeClient::new(node, None);
let info: Value = client
.get("/")
.await?
.json()
.await
let info: Value = client.get("/").await?.json().await
.context("failed to fetch node info")?;
let did = info["did"].as_str().context("node missing DID")?;
Ok(did.split(':').next_back().unwrap_or(did).to_string())
@@ -235,11 +163,7 @@ async fn cmd_create(
let keypair = load_keypair_from_dir(dir.as_deref())?;
// Derive owner DID before keypair is moved into the client
let owner_did = keypair.did().to_string();
let owner_short = owner_did
.split(':')
.next_back()
.unwrap_or(&owner_did)
.to_string();
let owner_short = owner_did.split(':').next_back().unwrap_or(&owner_did).to_string();
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&json!({
@@ -249,9 +173,7 @@ async fn cmd_create(
"default_branch": default_branch,
}))?;
let resp = client
.post("/api/v1/repos", &body)
.await
let resp = client.post("/api/v1/repos", &body).await
.context("failed to connect to node")?;
let status = resp.status();
let payload: Value = resp.json().await.context("invalid JSON response")?;
@@ -279,11 +201,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
let client = NodeClient::new(&node, None);
let url = format!("/api/v1/repos?owner={owner}");
let repos: Value = client
.get(&url)
.await?
.json()
.await
let repos: Value = client.get(&url).await?.json().await
.context("failed to list repos")?;
let repos = repos.as_array().context("expected array")?;
@@ -311,10 +229,7 @@ async fn cmd_clone(name: String, node: String, dir: Option<PathBuf>) -> Result<(
} else {
let client = NodeClient::new(&node, None);
let info: Value = client.get("/").await?.json().await?;
info["did"]
.as_str()
.context("node missing DID")?
.to_string()
info["did"].as_str().context("node missing DID")?.to_string()
};
let url = format!("gitlawb://{did}/{name}");
println!(" cloning {url}");
@@ -341,10 +256,8 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
};
let r: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}"))
.await?
.json()
.await
.get(&format!("/api/v1/repos/{owner}/{name}")).await?
.json().await
.context("repo not found")?;
let owner_did = r["owner_did"].as_str().unwrap_or(&owner);
@@ -355,130 +268,13 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
println!(" ID: {}", r["id"].as_str().unwrap_or("?"));
println!(" Owner DID: {owner_did}");
println!(" Public: {}", r["is_public"].as_bool().unwrap_or(true));
println!(
" Branch: {}",
r["default_branch"].as_str().unwrap_or("main")
);
println!(" Branch: {}", r["default_branch"].as_str().unwrap_or("main"));
println!(" Clone: git clone {gitlawb_url}");
println!(" HTTP URL: {http_url}");
println!(" Updated: {}", r["updated_at"].as_str().unwrap_or("?"));
if let Some(desc) = r["description"].as_str().filter(|s| !s.is_empty()) {
println!(" Desc: {desc}");
}
// Replica count — failure to fetch is non-fatal (older nodes don't expose this).
if let Ok(resp) = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
{
if resp.status().is_success() {
if let Ok(json) = resp.json::<Value>().await {
if let Some(count) = json["replica_count"].as_i64() {
println!(" Replicas: {count}");
}
}
}
}
Ok(())
}
async fn cmd_replica_register(
repo: String,
url: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format (e.g. did:key:.../myrepo)")?;
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let body = serde_json::to_vec(&json!({ "url": url }))?;
let resp = client
.put(&format!("/api/v1/repos/{owner}/{name}/replicas"), &body)
.await
.context("failed to connect to origin node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica register failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Registered as replica of {owner}/{name}");
println!(" Your URL: {url}");
println!(" Replicas: {count} total");
println!();
println!("Next: ensure your node has a copy of the repo —");
println!(" git clone gitlawb://{owner}/{name}");
Ok(())
}
async fn cmd_replica_unregister(repo: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let kp = load_keypair_from_dir(dir.as_deref())
.context("identity not found — run `gl identity new` first")?;
let client = NodeClient::new(&node, Some(kp));
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{name}/replicas"), b"")
.await
.context("failed to connect to origin node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replica unregister failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("Unregistered as replica of {owner}/{name} ({count} replicas remaining)");
Ok(())
}
async fn cmd_replicas(repo: String, node: String) -> Result<()> {
let (owner, name) = repo
.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format")?;
let client = NodeClient::new(&node, None);
let resp = client
.get(&format!("/api/v1/repos/{owner}/{name}/replicas"))
.await
.context("failed to connect to node")?;
let status = resp.status();
let body: Value = resp.json().await.unwrap_or_default();
if !status.is_success() {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("replicas list failed ({status}): {msg}");
}
let count = body["replica_count"].as_i64().unwrap_or(0);
println!("{owner}/{name}: {count} replicas");
if let Some(arr) = body["replicas"].as_array() {
for r in arr {
let did = r["replica_did"].as_str().unwrap_or("?");
let url = r["replica_url"].as_str().unwrap_or("?");
let registered = r["registered_at"]
.as_str()
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
println!(" {registered} {did} → {url}");
}
}
Ok(())
}
@@ -500,11 +296,7 @@ pub(crate) async fn cmd_commits(
};
let url = format!("/api/v1/repos/{owner}/{name}/commits?branch={branch}&limit={limit}");
let resp: Value = client
.get(&url)
.await?
.json()
.await
let resp: Value = client.get(&url).await?.json().await
.context("failed to fetch commits")?;
let commits = resp["commits"].as_array().cloned().unwrap_or_default();
@@ -516,20 +308,17 @@ pub(crate) async fn cmd_commits(
println!("Commits on {branch} ({owner}/{name})");
println!();
for c in &commits {
let sha = c["hash"]
.as_str()
let sha = c["hash"].as_str()
.or_else(|| c["sha"].as_str())
.or_else(|| c["oid"].as_str())
.unwrap_or("?");
let short_sha = &sha[..sha.len().min(10)];
let msg = c["message"].as_str().unwrap_or("(no message)");
let first_line = msg.lines().next().unwrap_or(msg);
let author = c["author_name"]
.as_str()
let author = c["author_name"].as_str()
.or_else(|| c["author"].as_str())
.unwrap_or("?");
let date = c["date"]
.as_str()
let date = c["date"].as_str()
.or_else(|| c["committer_date"].as_str())
.map(|s| &s[..10.min(s.len())])
.unwrap_or("?");
@@ -538,12 +327,7 @@ pub(crate) async fn cmd_commits(
Ok(())
}
async fn cmd_fork(
repo: String,
name: Option<String>,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_fork(repo: String, name: Option<String>, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let client = NodeClient::new(&node, Some(keypair));
@@ -554,9 +338,7 @@ async fn cmd_fork(
};
let body = serde_json::to_vec(&serde_json::json!({ "name": name }))?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{repo_name}/fork"), &body).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON response")?;
@@ -574,11 +356,7 @@ async fn cmd_fork(
}
/// Returns (owner, repo_name) — if repo contains '/', splits on it; otherwise uses caller's DID.
async fn resolve_owner_repo_pair(
repo: &str,
node: &str,
dir: Option<&std::path::Path>,
) -> Result<(String, String)> {
async fn resolve_owner_repo_pair(repo: &str, node: &str, dir: Option<&std::path::Path>) -> Result<(String, String)> {
if let Some((o, r)) = repo.split_once('/') {
Ok((o.to_string(), r.to_string()))
} else {
@@ -587,20 +365,13 @@ async fn resolve_owner_repo_pair(
}
}
async fn cmd_label_add(
repo: String,
label: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_label_add(repo: String, label: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let body = serde_json::to_vec(&serde_json::json!({ "label": label }))?;
let resp = client
.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body)
.await
let resp = client.post(&format!("/api/v1/repos/{owner}/{name}/labels"), &body).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -619,19 +390,12 @@ async fn cmd_label_add(
Ok(())
}
async fn cmd_label_remove(
repo: String,
label: String,
node: String,
dir: Option<PathBuf>,
) -> Result<()> {
async fn cmd_label_remove(repo: String, label: String, node: String, dir: Option<PathBuf>) -> Result<()> {
let keypair = load_keypair_from_dir(dir.as_deref())?;
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, Some(keypair));
let resp = client
.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[])
.await
let resp = client.delete(&format!("/api/v1/repos/{owner}/{name}/labels/{label}"), &[]).await
.context("failed to connect to node")?;
let status = resp.status();
let result: Value = resp.json().await.context("invalid JSON")?;
@@ -649,12 +413,8 @@ async fn cmd_label_list(repo: String, node: String, dir: Option<PathBuf>) -> Res
let (owner, name) = resolve_owner_repo_pair(&repo, &node, dir.as_deref()).await?;
let client = NodeClient::new(&node, None);
let resp: Value = client
.get(&format!("/api/v1/repos/{owner}/{name}/labels"))
.await?
.json()
.await
.context("invalid JSON")?;
let resp: Value = client.get(&format!("/api/v1/repos/{owner}/{name}/labels")).await?
.json().await.context("invalid JSON")?;
let labels = resp["labels"].as_array().cloned().unwrap_or_default();
if labels.is_empty() {
@@ -685,7 +445,10 @@ async fn cmd_owner(repo: String, node: String, dir: Option<PathBuf>, json_out: b
anyhow::bail!("repo not found: {owner}/{name}");
}
let info: Value = resp.json().await.context("invalid JSON")?;
let owner_did = info["owner_did"].as_str().unwrap_or(&owner).to_string();
let owner_did = info["owner_did"]
.as_str()
.unwrap_or(&owner)
.to_string();
let owner_short = owner_did
.split(':')
.next_back()
@@ -759,9 +522,7 @@ mod tests {
async fn test_resolve_owner_did_uses_keypair() {
let dir = TempDir::new().unwrap();
write_identity(&dir);
let owner = resolve_owner_did("http://unused", Some(dir.path()))
.await
.unwrap();
let owner = resolve_owner_did("http://unused", Some(dir.path())).await.unwrap();
// Should be the key segment of a did:key DID — starts with 'z'
assert!(owner.starts_with('z'));
assert!(!owner.contains(':'));
@@ -828,19 +589,14 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/repos\?owner=".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"[]"#)
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
@@ -857,9 +613,7 @@ mod tests {
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
}
#[tokio::test]
@@ -869,21 +623,14 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/repos\?owner=z".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/repos\?owner=z".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"myrepo","is_public":true,"updated_at":"2026-03-20T00:00:00Z"}]"#,
)
.with_body(r#"[{"name":"myrepo","is_public":true,"updated_at":"2026-03-20T00:00:00Z"}]"#)
.create_async()
.await;
cmd_list(server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
cmd_list(server.url(), Some(dir.path().to_path_buf())).await.unwrap();
_m.assert_async().await;
}
@@ -956,9 +703,7 @@ mod tests {
)
.with_status(201)
.with_header("content-type", "application/json")
.with_body(
r#"{"id":"fork-1","name":"myrepo","owner_did":"did:key:z6MkMe","is_public":true}"#,
)
.with_body(r#"{"id":"fork-1","name":"myrepo","owner_did":"did:key:z6MkMe","is_public":true}"#)
.create_async()
.await;
@@ -997,10 +742,7 @@ mod tests {
)
.await
.unwrap_err();
assert!(
err.to_string().contains("already have a repo"),
"got: {err}"
);
assert!(err.to_string().contains("already have a repo"), "got: {err}");
}
#[tokio::test]
@@ -1055,9 +797,7 @@ mod tests {
let _m = server
.mock(
"DELETE",
mockito::Matcher::Regex(
r"^/api/v1/repos/[^/]+/myrepo/labels/language:rust$".to_string(),
),
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/labels/language:rust$".to_string()),
)
.with_status(200)
.with_header("content-type", "application/json")
@@ -1092,13 +832,9 @@ mod tests {
.create_async()
.await;
cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_label_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -1118,13 +854,9 @@ mod tests {
.create_async()
.await;
cmd_label_list(
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
cmd_label_list("myrepo".to_string(), server.url(), Some(dir.path().to_path_buf()))
.await
.unwrap();
}
#[tokio::test]
@@ -1144,9 +876,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"name":"myrepo","owner_did":"{did}","is_public":true}}"#
))
.with_body(format!(r#"{{"name":"myrepo","owner_did":"{did}","is_public":true}}"#))
.create_async()
.await;
let _prot = server
@@ -1185,9 +915,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#,
)
.with_body(r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#)
.create_async()
.await;
let _prot = server
@@ -1226,9 +954,7 @@ mod tests {
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#,
)
.with_body(r#"{"name":"myrepo","owner_did":"did:key:z6MkSomeOtherOwner","is_public":true}"#)
.create_async()
.await;
let _prot = server
+22 -59
View File
@@ -45,9 +45,9 @@ pub enum StarCmd {
pub async fn run(args: StarArgs) -> Result<()> {
match args.cmd {
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
StarCmd::Add { repo, node, dir } => cmd_add(repo, node, dir).await,
StarCmd::Remove { repo, node, dir } => cmd_remove(repo, node, dir).await,
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
StarCmd::Count { repo, node } => cmd_count(repo, node).await,
}
}
@@ -55,8 +55,8 @@ fn resolve_owner_repo(repo: &str, dir: Option<&std::path::Path>) -> Result<(Stri
if let Some((owner, name)) = repo.split_once('/') {
return Ok((owner.to_string(), name.to_string()));
}
let kp =
load_keypair_from_dir(dir).context("identity not found — run `gl identity new` first")?;
let kp = load_keypair_from_dir(dir)
.context("identity not found — run `gl identity new` first")?;
let did = kp.did().to_string();
let short = did.split(':').next_back().unwrap_or(&did).to_string();
Ok((short, repo.to_string()))
@@ -111,8 +111,7 @@ async fn cmd_remove(repo: String, node: String, dir: Option<PathBuf>) -> Result<
}
async fn cmd_count(repo: String, node: String) -> Result<()> {
let (owner, name) = repo
.split_once('/')
let (owner, name) = repo.split_once('/')
.map(|(o, n)| (o.to_string(), n.to_string()))
.context("use owner/repo format for count (e.g. alice/myrepo)")?;
let client = NodeClient::new(&node, None);
@@ -144,17 +143,10 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock(
"PUT",
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/star$".to_string()),
)
.mock("PUT", mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/star$".to_string()))
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"starred","repo":"z/myrepo","star_count":1}"#)
@@ -165,9 +157,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -175,11 +165,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -194,9 +180,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -206,9 +190,7 @@ mod tests {
"owner/myrepo".to_string(),
"http://127.0.0.1:1".to_string(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
).await.unwrap_err();
assert!(err.to_string().contains("identity not found"));
}
@@ -217,11 +199,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("PUT", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -235,9 +213,7 @@ mod tests {
"owner/missing".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
).await.unwrap_err();
assert!(err.to_string().contains("star failed"));
}
@@ -246,11 +222,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -264,9 +236,7 @@ mod tests {
"myrepo".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap();
).await.unwrap();
}
#[tokio::test]
@@ -274,11 +244,7 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let dir = tempfile::TempDir::new().unwrap();
let kp = gitlawb_core::identity::Keypair::generate();
std::fs::write(
dir.path().join("identity.pem"),
kp.to_pem().unwrap().as_bytes(),
)
.unwrap();
std::fs::write(dir.path().join("identity.pem"), kp.to_pem().unwrap().as_bytes()).unwrap();
let _m = server
.mock("DELETE", mockito::Matcher::Regex(r"/star$".to_string()))
@@ -292,9 +258,7 @@ mod tests {
"owner/missing".to_string(),
server.url(),
Some(dir.path().to_path_buf()),
)
.await
.unwrap_err();
).await.unwrap_err();
assert!(err.to_string().contains("unstar failed"));
}
@@ -310,16 +274,15 @@ mod tests {
.create_async()
.await;
cmd_count("alice/myrepo".to_string(), server.url())
.await
.unwrap();
cmd_count("alice/myrepo".to_string(), server.url()).await.unwrap();
}
#[tokio::test]
async fn test_cmd_count_requires_slash() {
let err = cmd_count("noslash".to_string(), "http://127.0.0.1:1".to_string())
.await
.unwrap_err();
let err = cmd_count(
"noslash".to_string(),
"http://127.0.0.1:1".to_string(),
).await.unwrap_err();
assert!(err.to_string().contains("owner/repo format"));
}
+9 -29
View File
@@ -84,8 +84,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
if let Ok(r) = pr_resp {
if let Ok(body) = r.json::<Value>().await {
let prs = body["pulls"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = prs
.iter()
let open: Vec<_> = prs.iter()
.filter(|p| p["status"].as_str() == Some("open"))
.collect();
if open.is_empty() {
@@ -111,8 +110,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
if let Ok(r) = issue_resp {
if let Ok(body) = r.json::<Value>().await {
let issues = body["issues"].as_array().cloned().unwrap_or_default();
let open: Vec<_> = issues
.iter()
let open: Vec<_> = issues.iter()
.filter(|i| i["status"].as_str() == Some("open"))
.collect();
if open.is_empty() {
@@ -150,17 +148,13 @@ fn detect_gitlawb_remote() -> Option<(String, String)> {
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
if !out.status.success() { return None; }
let url = String::from_utf8(out.stdout).ok()?;
let rest = url.trim().strip_prefix("gitlawb://")?;
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() {
return None;
}
if did.is_empty() || repo.is_empty() { return None; }
Some((did, repo))
}
@@ -171,9 +165,7 @@ fn parse_gitlawb_url(url: &str) -> Option<(String, String)> {
let slash = rest.rfind('/')?;
let did = rest[..slash].to_string();
let repo = rest[slash + 1..].to_string();
if did.is_empty() || repo.is_empty() {
return None;
}
if did.is_empty() || repo.is_empty() { return None; }
Some((did, repo))
}
@@ -184,19 +176,13 @@ mod tests {
#[test]
fn parse_simple_gitlawb_url() {
let result = parse_gitlawb_url("gitlawb://did:key:z6Mk1234/myrepo");
assert_eq!(
result,
Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string()))
);
assert_eq!(result, Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string())));
}
#[test]
fn parse_gitlawb_url_with_newline() {
let result = parse_gitlawb_url("gitlawb://did:key:z6Mk1234/myrepo\n");
assert_eq!(
result,
Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string()))
);
assert_eq!(result, Some(("did:key:z6Mk1234".to_string(), "myrepo".to_string())));
}
#[test]
@@ -218,10 +204,7 @@ mod tests {
#[test]
fn parse_gitlawb_url_repo_name_with_dash() {
let result = parse_gitlawb_url("gitlawb://did:key:z6MkAbc/my-cool-repo");
assert_eq!(
result,
Some(("did:key:z6MkAbc".to_string(), "my-cool-repo".to_string()))
);
assert_eq!(result, Some(("did:key:z6MkAbc".to_string(), "my-cool-repo".to_string())));
}
#[tokio::test]
@@ -296,10 +279,7 @@ mod tests {
.create_async()
.await;
let _trust = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()),
)
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/agents/".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"not found"}"#)
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more