Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ jobs:
- name: cargo fmt --check
run: cargo fmt --all -- --check

- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# TODO: tighten to `-- -D warnings` once existing lints are cleaned up
- name: cargo clippy (advisory)
run: cargo clippy --workspace --all-targets
continue-on-error: true

- name: cargo test
run: cargo test --workspace
15 changes: 15 additions & 0 deletions bootstrap-peers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$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-04-29",
"peers": [
{
"name": "gitlawb",
"operator": "Gitlawb (Kevin)",
"did": "did:key:z6MkqDnb7Siv3Cwj7pGJq4T5EsUisECqR8KpnDLwcaZq5TPr",
"http_url": "https://node.gitlawb.com",
"p2p_multiaddr": null,
"added": "2026-04-29"
}
]
}
48 changes: 24 additions & 24 deletions crates/git-remote-gitlawb/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,16 @@
//! 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 std::io::{self, BufRead, Read, Write};
use anyhow::{bail, Context, Result};
use gitlawb_core::identity::Keypair;
use gitlawb_core::http_sig::sign_request;
use gitlawb_core::identity::Keypair;
use std::io::{self, BufRead, Read, Write};

fn main() -> Result<()> {
// 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();
Expand All @@ -42,8 +40,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}");

Expand All @@ -62,7 +60,9 @@ 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
}
Expand Down Expand Up @@ -149,7 +149,10 @@ 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)?;
Expand All @@ -172,7 +175,9 @@ 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
};

Expand All @@ -192,10 +197,7 @@ 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());

Expand All @@ -215,9 +217,7 @@ 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());
Expand Down Expand Up @@ -296,7 +296,9 @@ 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
Expand Down Expand Up @@ -380,8 +382,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());
Expand All @@ -399,17 +401,15 @@ 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");
}

#[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");
}

Expand Down
30 changes: 16 additions & 14 deletions crates/gitlawb-core/src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{Error, Result};
use crate::did::Did;
use crate::identity::{Keypair, verify};
use crate::identity::{verify, Keypair};
use crate::{Error, Result};

/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
Expand Down Expand Up @@ -110,18 +110,20 @@ impl RefUpdateCert {
///
/// Returns the list of DIDs whose signatures are valid.
pub fn verify_all(&self) -> Result<Vec<Did>> {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let signing_bytes = self.body.to_signing_bytes()?;
let mut valid = Vec::new();

for cert_sig in &self.signatures {
// 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)?;
Expand All @@ -133,11 +135,7 @@ 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)
Expand Down Expand Up @@ -187,7 +185,8 @@ mod tests {
dummy_hash('a'),
1,
&kp,
).unwrap();
)
.unwrap();

cert.validate_structure().unwrap();
let valid = cert.verify_all().unwrap();
Expand All @@ -208,7 +207,8 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
).unwrap();
)
.unwrap();

cert.countersign(&kp2).unwrap();
let valid = cert.verify_all().unwrap();
Expand All @@ -228,7 +228,8 @@ mod tests {
dummy_hash('a'),
1,
&kp1,
).unwrap();
)
.unwrap();
cert.countersign(&kp2).unwrap();

let maintainers = vec![kp1.did(), kp2.did()];
Expand All @@ -246,7 +247,8 @@ 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"));
Expand Down
13 changes: 8 additions & 5 deletions crates/gitlawb-core/src/cid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use cid::CidGeneric;
use multihash_codetable::{Code, MultihashDigest};
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
use std::fmt;

use crate::{Error, Result};
Expand Down Expand Up @@ -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()))
}

Expand All @@ -110,7 +110,10 @@ 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]
Expand Down
18 changes: 11 additions & 7 deletions crates/gitlawb-core/src/did.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,13 @@ 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(
Expand All @@ -92,8 +93,7 @@ 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`.
Expand All @@ -105,7 +105,9 @@ 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}"
))),
}
}
}
Expand All @@ -121,7 +123,9 @@ 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()?;
Expand Down
Loading
Loading