don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

style: commit to naming

Signed-off-by: tjh <x@tjh.dev>

tjh a4e76c4b 87402ca4

+11162 -11031
+139 -139
Cargo.lock
··· 144 144 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 145 145 146 146 [[package]] 147 - name = "atproto" 148 - version = "0.0.0" 149 - dependencies = [ 150 - "serde", 151 - "serde_json", 152 - "smallstr", 153 - "sqlx", 154 - "thiserror 2.0.18", 155 - "time", 156 - ] 157 - 158 - [[package]] 159 - name = "auth" 160 - version = "0.0.0" 161 - dependencies = [ 162 - "atproto", 163 - "aws-lc-rs", 164 - "data-encoding", 165 - "exn", 166 - "identity", 167 - "multibase", 168 - "serde", 169 - "serde_json", 170 - "thiserror 2.0.18", 171 - "url", 172 - ] 173 - 174 - [[package]] 175 147 name = "autocfg" 176 148 version = "1.5.0" 177 149 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 559 587 version = "0.0.0" 560 588 dependencies = [ 561 589 "anyhow", 562 - "atproto", 563 - "auth", 564 590 "axum", 565 591 "clap", 566 592 "data-encoding", 567 593 "directories", 568 - "identity", 594 + "gordian-auth", 595 + "gordian-identity", 596 + "gordian-types", 569 597 "owo-colors", 570 598 "rand 0.9.2", 571 599 "reqwest", ··· 2071 2099 ] 2072 2100 2073 2101 [[package]] 2102 + name = "gordian-auth" 2103 + version = "0.0.0" 2104 + dependencies = [ 2105 + "aws-lc-rs", 2106 + "data-encoding", 2107 + "exn", 2108 + "gordian-identity", 2109 + "gordian-types", 2110 + "multibase", 2111 + "serde", 2112 + "serde_json", 2113 + "thiserror 2.0.18", 2114 + "url", 2115 + ] 2116 + 2117 + [[package]] 2118 + name = "gordian-identity" 2119 + version = "0.0.0" 2120 + dependencies = [ 2121 + "futures-util", 2122 + "gordian-types", 2123 + "hickory-resolver", 2124 + "moka", 2125 + "reqwest", 2126 + "serde", 2127 + "serde_json", 2128 + "thiserror 2.0.18", 2129 + "tokio", 2130 + "tracing", 2131 + "tracing-subscriber", 2132 + "url", 2133 + ] 2134 + 2135 + [[package]] 2136 + name = "gordian-jetstream" 2137 + version = "0.0.0" 2138 + dependencies = [ 2139 + "bytes", 2140 + "clap", 2141 + "fastrand", 2142 + "flume", 2143 + "futures-util", 2144 + "gordian-types", 2145 + "serde", 2146 + "serde_json", 2147 + "thiserror 2.0.18", 2148 + "time", 2149 + "tokio", 2150 + "tokio-tungstenite", 2151 + "tokio-util", 2152 + "tracing", 2153 + "tracing-subscriber", 2154 + "url", 2155 + "zstd", 2156 + ] 2157 + 2158 + [[package]] 2159 + name = "gordian-knot" 2160 + version = "0.0.0" 2161 + dependencies = [ 2162 + "anyhow", 2163 + "aws-lc-rs", 2164 + "axum", 2165 + "axum-extra", 2166 + "bytes", 2167 + "clap", 2168 + "clap_complete", 2169 + "dashmap", 2170 + "data-encoding", 2171 + "futures-util", 2172 + "git-service", 2173 + "gix", 2174 + "gordian-auth", 2175 + "gordian-identity", 2176 + "gordian-jetstream", 2177 + "gordian-lexicon", 2178 + "gordian-types", 2179 + "http-body-util", 2180 + "hyper-util", 2181 + "mimetype-detector", 2182 + "mock-pds", 2183 + "moka", 2184 + "multibase", 2185 + "rand 0.9.2", 2186 + "rayon", 2187 + "reqwest", 2188 + "rustc-hash", 2189 + "serde", 2190 + "serde_json", 2191 + "sqlx", 2192 + "tempfile", 2193 + "thiserror 2.0.18", 2194 + "tikv-jemallocator", 2195 + "time", 2196 + "tokio", 2197 + "tokio-rayon", 2198 + "tokio-stream", 2199 + "tokio-tungstenite", 2200 + "tokio-util", 2201 + "tower", 2202 + "tower-http", 2203 + "tracing", 2204 + "tracing-subscriber", 2205 + "url", 2206 + ] 2207 + 2208 + [[package]] 2209 + name = "gordian-lexicon" 2210 + version = "0.0.0" 2211 + dependencies = [ 2212 + "data-encoding", 2213 + "gix-hash", 2214 + "gordian-identity", 2215 + "gordian-types", 2216 + "serde", 2217 + "serde_json", 2218 + "thiserror 2.0.18", 2219 + "time", 2220 + ] 2221 + 2222 + [[package]] 2223 + name = "gordian-types" 2224 + version = "0.0.0" 2225 + dependencies = [ 2226 + "serde", 2227 + "serde_json", 2228 + "smallstr", 2229 + "sqlx", 2230 + "thiserror 2.0.18", 2231 + "time", 2232 + ] 2233 + 2234 + [[package]] 2074 2235 name = "group" 2075 2236 version = "0.13.0" 2076 2237 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2577 2472 ] 2578 2473 2579 2474 [[package]] 2580 - name = "identity" 2581 - version = "0.0.0" 2582 - dependencies = [ 2583 - "atproto", 2584 - "futures-util", 2585 - "hickory-resolver", 2586 - "moka", 2587 - "reqwest", 2588 - "serde", 2589 - "serde_json", 2590 - "thiserror 2.0.18", 2591 - "tokio", 2592 - "tracing", 2593 - "tracing-subscriber", 2594 - "url", 2595 - ] 2596 - 2597 - [[package]] 2598 2475 name = "idna" 2599 2476 version = "1.1.0" 2600 2477 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2706 2619 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 2707 2620 2708 2621 [[package]] 2709 - name = "jetstream" 2710 - version = "0.0.0" 2711 - dependencies = [ 2712 - "atproto", 2713 - "bytes", 2714 - "clap", 2715 - "fastrand", 2716 - "flume", 2717 - "futures-util", 2718 - "serde", 2719 - "serde_json", 2720 - "thiserror 2.0.18", 2721 - "time", 2722 - "tokio", 2723 - "tokio-tungstenite", 2724 - "tokio-util", 2725 - "tracing", 2726 - "tracing-subscriber", 2727 - "url", 2728 - "zstd", 2729 - ] 2730 - 2731 - [[package]] 2732 2622 name = "jiff" 2733 2623 version = "0.2.18" 2734 2624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2789 2725 ] 2790 2726 2791 2727 [[package]] 2792 - name = "knot" 2793 - version = "0.0.0" 2794 - dependencies = [ 2795 - "anyhow", 2796 - "atproto", 2797 - "auth", 2798 - "aws-lc-rs", 2799 - "axum", 2800 - "axum-extra", 2801 - "bytes", 2802 - "clap", 2803 - "clap_complete", 2804 - "dashmap", 2805 - "data-encoding", 2806 - "futures-util", 2807 - "git-service", 2808 - "gix", 2809 - "http-body-util", 2810 - "hyper-util", 2811 - "identity", 2812 - "jetstream", 2813 - "lexicon", 2814 - "mimetype-detector", 2815 - "mock-pds", 2816 - "moka", 2817 - "multibase", 2818 - "rand 0.9.2", 2819 - "rayon", 2820 - "reqwest", 2821 - "rustc-hash", 2822 - "serde", 2823 - "serde_json", 2824 - "sqlx", 2825 - "tempfile", 2826 - "thiserror 2.0.18", 2827 - "tikv-jemallocator", 2828 - "time", 2829 - "tokio", 2830 - "tokio-rayon", 2831 - "tokio-stream", 2832 - "tokio-tungstenite", 2833 - "tokio-util", 2834 - "tower", 2835 - "tower-http", 2836 - "tracing", 2837 - "tracing-subscriber", 2838 - "url", 2839 - ] 2840 - 2841 - [[package]] 2842 2728 name = "kstring" 2843 2729 version = "2.0.2" 2844 2730 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2804 2790 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2805 2791 dependencies = [ 2806 2792 "spin", 2807 - ] 2808 - 2809 - [[package]] 2810 - name = "lexicon" 2811 - version = "0.0.0" 2812 - dependencies = [ 2813 - "atproto", 2814 - "data-encoding", 2815 - "gix-hash", 2816 - "identity", 2817 - "serde", 2818 - "serde_json", 2819 - "thiserror 2.0.18", 2820 - "time", 2821 2793 ] 2822 2794 2823 2795 [[package]] ··· 2972 2972 name = "mock-pds" 2973 2973 version = "0.0.0" 2974 2974 dependencies = [ 2975 - "atproto", 2976 - "auth", 2977 2975 "aws-lc-rs", 2978 2976 "axum", 2979 2977 "data-encoding", 2980 2978 "futures-util", 2981 - "identity", 2979 + "gordian-auth", 2980 + "gordian-identity", 2981 + "gordian-types", 2982 2982 "multibase", 2983 2983 "serde", 2984 2984 "serde_json",
+12 -12
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "3" 3 3 members = [ 4 - "crates/atproto", 5 - "crates/auth", 4 + "crates/gordian-auth", 5 + "crates/gordian-types", 6 6 "crates/credential-helper", 7 - "crates/identity", 8 - "crates/jetstream", 9 - "crates/knot", 10 - "crates/lexicon", 7 + "crates/gordian-identity", 8 + "crates/gordian-jetstream", 9 + "crates/gordian-knot", 10 + "crates/gordian-lexicon", 11 11 "crates/git-service", "crates/mock-pds", 12 12 ] 13 - default-members = ["crates/knot"] 13 + default-members = ["crates/gordian-knot"] 14 14 15 15 [workspace.package] 16 16 version = "0.0.0" ··· 21 21 publish = false 22 22 23 23 [workspace.dependencies] 24 - atproto = { path = "crates/atproto" } 25 - auth = { path = "crates/auth" } 26 - identity = { path = "crates/identity" } 27 - jetstream = { path = "crates/jetstream" } 28 - lexicon = { path = "crates/lexicon" } 24 + gordian-types = { path = "crates/gordian-types" } 25 + gordian-auth = { path = "crates/gordian-auth" } 26 + gordian-identity = { path = "crates/gordian-identity" } 27 + gordian-jetstream = { path = "crates/gordian-jetstream" } 28 + gordian-lexicon = { path = "crates/gordian-lexicon" } 29 29 git-service = { path = "crates/git-service"} 30 30 31 31 anyhow = "1.0.100"
+8 -9
README.md
··· 1 - # gordian 1 + # Gordian 2 2 3 - ## crates 3 + ## Crates 4 4 5 - - [atproto](crates/atproto): primitive atproto types. 6 - - [auth](crates/auth): wip atproto auth junk. 7 - - [credential-helper](crates/credential-helper): dodgy git-credential-helper. 8 - - [identity](crates/identity): janky atproto identity resolver. 9 - - [jetstream](crates/jetstream): jetstream client. 10 - - [knot](crates/knot): 🦀 knot 11 - - [lexicon](crates/lexicon): artisinal jumble of hand-written lexicon types. 5 + - [gordian-auth](crates/gordian-auth) 6 + - [gordian-identity](crates/gordian-identity): Atmosphere identity resolver 7 + - [gordian-jetstream](crates/gordian-jetstream): Jetstream client 8 + - [gordian-knot](crates/gordian-knot): Knot server 9 + - [gordian-lexicon](crates/gordian-lexicon): Lexicon 10 + - [gordian-types](crates/gordian-types): Basic Atmosphere types
+1 -1
contrib/gordian-knot.service
··· 7 7 [Service] 8 8 Environment=KNOT_REPO_BASE=/var/lib/tangled 9 9 WorkingDirectory=/var/lib/tangled 10 - ExecStart=/usr/bin/knot serve 10 + ExecStart=/usr/bin/gordian-knot serve 11 11 Restart=always 12 12 User=git 13 13 Group=git
-19
crates/atproto/Cargo.toml
··· 1 - [package] 2 - name = "atproto" 3 - version.workspace = true 4 - authors.workspace = true 5 - repository.workspace = true 6 - license.workspace = true 7 - edition.workspace = true 8 - publish.workspace = true 9 - 10 - [dependencies] 11 - smallstr = { version = "0.3.1" } 12 - thiserror.workspace = true 13 - 14 - serde = { workspace = true, optional = true } 15 - sqlx = { version = "0.8.6", optional = true } 16 - time = { workspace = true, optional = true } 17 - 18 - [dev-dependencies] 19 - serde_json.workspace = true
crates/atproto/src/aturi.rs crates/gordian-types/src/aturi.rs
crates/atproto/src/did.rs crates/gordian-types/src/did.rs
crates/atproto/src/handle.rs crates/gordian-types/src/handle.rs
crates/atproto/src/lib.rs crates/gordian-types/src/lib.rs
crates/atproto/src/macros.rs crates/gordian-types/src/macros.rs
crates/atproto/src/nsid.rs crates/gordian-types/src/nsid.rs
crates/atproto/src/serde.rs crates/gordian-types/src/serde.rs
crates/atproto/src/tid.rs crates/gordian-types/src/tid.rs
crates/atproto/src/uri.rs crates/gordian-types/src/uri.rs
crates/atproto/src/util.rs crates/gordian-types/src/util.rs
-22
crates/auth/Cargo.toml
··· 1 - [package] 2 - name = "auth" 3 - version.workspace = true 4 - authors.workspace = true 5 - repository.workspace = true 6 - license.workspace = true 7 - edition.workspace = true 8 - publish.workspace = true 9 - 10 - [dependencies] 11 - atproto = { workspace = true, features = ["serde"] } 12 - identity.workspace = true 13 - 14 - serde.workspace = true 15 - serde_json = { workspace = true, features = ["preserve_order"] } 16 - thiserror.workspace = true 17 - url.workspace = true 18 - 19 - aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 20 - data-encoding = "2.9.0" 21 - multibase = "0.9.1" 22 - exn = "0.3.0"
-257
crates/auth/src/jwt.rs
··· 1 - use core::fmt; 2 - 3 - use atproto::{Nsid, did::OwnedDid}; 4 - use data_encoding::BASE64URL_NOPAD as Encoding; 5 - use serde::{Deserialize, Serialize, de::DeserializeOwned}; 6 - 7 - use crate::verification_key::{Unspecified, VerificationKey}; 8 - 9 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 10 - pub enum Type { 11 - JWT, 12 - } 13 - 14 - /// Signature algorithm. 15 - /// 16 - /// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt> 17 - #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] 18 - #[non_exhaustive] 19 - pub enum Algorithm { 20 - ES256K, 21 - ES256, 22 - ES384, 23 - ES512, 24 - EdDSA, 25 - } 26 - 27 - impl fmt::Display for Algorithm { 28 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 - fmt::Debug::fmt(self, f) 30 - } 31 - } 32 - 33 - /// See: <https://docs.rs/jose-jwk/latest/jose_jwk/enum.OkpCurves.html> 34 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 35 - #[non_exhaustive] 36 - pub enum Curve { 37 - Ed25519, 38 - } 39 - 40 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 41 - pub struct Header { 42 - pub typ: Type, 43 - 44 - /// Signing-key algorithm. 45 - pub alg: Algorithm, 46 - 47 - #[serde(skip_serializing_if = "Option::is_none")] 48 - pub crv: Option<Curve>, 49 - } 50 - 51 - impl Header { 52 - #[must_use] 53 - pub const fn new(alg: Algorithm, crv: Option<Curve>) -> Self { 54 - Self { 55 - typ: Type::JWT, 56 - alg, 57 - crv, 58 - } 59 - } 60 - } 61 - 62 - /// Standard claims for inter-service authentication (JWT). 63 - /// 64 - /// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt> 65 - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 66 - pub struct Claims { 67 - /// Account DID associated with the service that the request is being 68 - /// sent to. 69 - pub iss: OwnedDid, 70 - 71 - /// Service DID associated with the service that the request is being 72 - /// sent to. 73 - pub aud: OwnedDid, 74 - 75 - /// Token creation time as a UNIX timestamp. 76 - pub iat: i64, 77 - 78 - /// Token expiration time as a UNIX timestamp. 79 - pub exp: i64, 80 - 81 - /// Lexicon method in NSID syntax. 82 - #[serde(skip_serializing_if = "Option::is_none")] 83 - pub lxm: Option<Box<Nsid>>, 84 - 85 - /// Nonce. 86 - pub jti: Box<str>, 87 - } 88 - 89 - #[derive(Debug, Deserialize, Serialize)] 90 - pub struct Token<C = Claims> { 91 - pub header: Header, 92 - pub claims: C, 93 - } 94 - 95 - impl Token<Claims> { 96 - #[inline] 97 - pub fn decode( 98 - token: impl AsRef<[u8]>, 99 - public_key: &dyn VerificationKey, 100 - ) -> Result<Self, Error> { 101 - decode(token, public_key) 102 - } 103 - 104 - #[inline] 105 - pub fn decode_unverified(token: impl AsRef<[u8]>) -> Result<Self, Error> { 106 - decode_unverified(token) 107 - } 108 - } 109 - 110 - #[derive(Debug, thiserror::Error)] 111 - pub enum Error { 112 - #[error("Invalid token format")] 113 - InvalidFormat, 114 - #[error("Invalid token encoding: {0}")] 115 - InvalidEncoding(#[from] data_encoding::DecodeError), 116 - #[error("Failed to parse token: {0}")] 117 - InvalidStructure(#[from] serde_json::Error), 118 - #[error("Serialized header or claims do not match input")] 119 - SerializationMismatch, 120 - #[error("Unsupported signature algorithm '{0}'")] 121 - UnsupportedAlgorithm(Algorithm), 122 - #[error("Signature failure")] 123 - SignatureFailed(#[from] Unspecified), 124 - } 125 - 126 - type TokenParts<'a> = (&'a [u8], &'a [u8], &'a [u8]); 127 - 128 - fn split_token(bytes: &[u8]) -> Result<TokenParts<'_>, Error> { 129 - let mut parts = bytes.split(|bytes| bytes == &b'.'); 130 - match (parts.next(), parts.next(), parts.next(), parts.next()) { 131 - (Some(header), Some(claims), Some(signature), None) => Ok((header, claims, signature)), 132 - _ => Err(Error::InvalidFormat), 133 - } 134 - } 135 - 136 - fn parse<T: DeserializeOwned + Serialize>(encoded_bytes: &[u8]) -> Result<T, Error> { 137 - let bytes = Encoding.decode(encoded_bytes)?; 138 - 139 - // Verify the deserialized input matches the decoded bytes when re-serialized. 140 - let value: serde_json::Value = serde_json::from_slice(&bytes)?; 141 - let serialized = serde_json::to_vec(&value)?; 142 - if bytes != serialized { 143 - return Err(Error::SerializationMismatch); 144 - } 145 - 146 - Ok(serde_json::from_value(value)?) 147 - } 148 - 149 - /// Get the deserialized [`Token`] without verifying the `token`'s signature. 150 - /// 151 - pub fn decode_unverified<C: DeserializeOwned + Serialize>( 152 - token: impl AsRef<[u8]>, 153 - ) -> Result<Token<C>, Error> { 154 - let (header, claims, _) = split_token(token.as_ref())?; 155 - 156 - let header = parse(header)?; 157 - let claims = parse(claims)?; 158 - 159 - Ok(Token { header, claims }) 160 - } 161 - 162 - /// Verify the JWT signature using `verification_key` and return the deserialized [`Token`]. 163 - /// 164 - pub fn decode<C: DeserializeOwned + Serialize>( 165 - token: impl AsRef<[u8]>, 166 - verification_key: &dyn VerificationKey, 167 - ) -> Result<Token<C>, Error> { 168 - let token = token.as_ref(); 169 - let (header, claims, signature) = split_token(token)?; 170 - 171 - // The message to verify is the base64 encoded header and claims section. 172 - let message = &token[..=header.len() + claims.len()]; 173 - let header: Header = parse(header)?; 174 - 175 - if verification_key 176 - .algorithm() 177 - .is_some_and(|&alg| alg != header.alg) 178 - { 179 - return Err(Error::UnsupportedAlgorithm(header.alg)); 180 - } 181 - 182 - // Decode the signature bytes and verify. 183 - let signature = Encoding.decode(signature)?; 184 - verification_key.verify_sig(message, &signature)?; 185 - 186 - let claims = parse(claims)?; 187 - 188 - Ok(Token { header, claims }) 189 - } 190 - 191 - #[cfg(test)] 192 - mod tests { 193 - use atproto::did::Did; 194 - use identity::VerificationMethod; 195 - 196 - use super::{Algorithm, Error, Token, Type}; 197 - 198 - #[test] 199 - fn can_split_token() { 200 - use super::split_token; 201 - 202 - assert!(split_token(b"").is_err()); 203 - assert!(split_token(b"header").is_err()); 204 - assert!(split_token(b"header.claims").is_err()); 205 - 206 - let (h, c, s) = split_token(b"header.claims.signature").unwrap(); 207 - assert_eq!(h, b"header"); 208 - assert_eq!(c, b"claims"); 209 - assert_eq!(s, b"signature"); 210 - } 211 - 212 - const TOKEN: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NTg2NjE4ODUsImlzcyI6ImRpZDpwbGM6NjVnaGE0dDNhdnBmcHptdnBid292c3M3IiwiYXVkIjoiZGlkOndlYjpnb3JkaWFuLWRldjo1NTU1IiwiZXhwIjoxNzU4NjYxOTQ1LCJseG0iOiJzaC50YW5nbGVkLnJlcG8uY3JlYXRlIiwianRpIjoiY2Y0ZDE5YTIwNDE0YWMzMjk2NTI3NzBkYzIzYjUzNTYifQ.llMTh_dC72uV3A9STs8yTFAo8jO9XUJnK-m8eA4wZ0EZXeLpxQn3oviFH22eh9_SEKtj9y0YXCfWCafVJre8qg"; 213 - 214 - #[test] 215 - fn can_decode_token() { 216 - let Token { header, claims } = Token::decode_unverified(TOKEN).unwrap(); 217 - 218 - assert_eq!(header.typ, Type::JWT); 219 - assert_eq!(header.alg, Algorithm::ES256K); 220 - assert_eq!( 221 - claims.aud.as_ref(), 222 - Did::from_static("did:web:gordian-dev:5555") 223 - ); 224 - assert_eq!( 225 - claims.iss.as_ref(), 226 - Did::from_static("did:plc:65gha4t3avpfpzmvpbwovss7") 227 - ); 228 - } 229 - 230 - #[test] 231 - fn parse_rejects_junk_after_struct() { 232 - let claims = r#"{"iss":"did:plc:65gha4t3avpfpzmvpbwovss7","aud":"did:web:gordian.incus","iat":0,"exp":10,"jti":"totally_random_bytes"} "#; 233 - let encoded = super::Encoding.encode(claims.as_bytes()); 234 - assert!(matches!( 235 - super::parse::<super::Claims>(encoded.as_bytes()), 236 - Err(Error::SerializationMismatch) 237 - )); 238 - } 239 - 240 - #[test] 241 - fn can_verify_token() { 242 - let vm = VerificationMethod::Multikey { 243 - id: "".to_string(), 244 - controller: "did:web:test".try_into().unwrap(), 245 - public_key_multibase: "zQ3shNWn4uG62Nv3dkggV5dGiN7bHK2w2tX2QxtKpVCvDK4Ff".to_string(), 246 - }; 247 - 248 - let Token { header, claims } = Token::decode(TOKEN, &vm).unwrap(); 249 - 250 - assert_eq!(header.typ, Type::JWT); 251 - assert_eq!(header.alg, Algorithm::ES256K); 252 - assert_eq!( 253 - claims.aud.as_ref(), 254 - Did::from_static("did:web:gordian-dev:5555") 255 - ); 256 - } 257 - }
crates/auth/src/lib.rs crates/gordian-auth/src/lib.rs
crates/auth/src/resources.rs crates/gordian-auth/src/resources.rs
crates/auth/src/support_set.rs crates/gordian-auth/src/support_set.rs
crates/auth/src/types.rs crates/gordian-auth/src/types.rs
crates/auth/src/validated_url.rs crates/gordian-auth/src/validated_url.rs
-363
crates/auth/src/verification_key.rs
··· 1 - use exn::Exn; 2 - 3 - pub trait VerificationKey: Send { 4 - /// Use the verification key to verify that `signature` is a valid signature of 5 - /// `message`. 6 - fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified>; 7 - 8 - fn algorithm(&self) -> Option<&Algorithm> { 9 - None 10 - } 11 - } 12 - 13 - pub trait IntoVerificationKey { 14 - type Output: VerificationKey; 15 - 16 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>>; 17 - } 18 - 19 - impl VerificationKey for Box<dyn VerificationKey> { 20 - fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 21 - (**self).verify_sig(message, signature) 22 - } 23 - } 24 - 25 - impl<T, K> VerificationKey for T 26 - where 27 - T: IntoVerificationKey<Output = K> + Send, 28 - K: VerificationKey, 29 - { 30 - fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 31 - let key = self.into_verification_key().map_err(|_| Unspecified)?; 32 - key.verify_sig(message, signature) 33 - } 34 - } 35 - 36 - /// Replicates [`aws_lc_rc::errors::Unspecified`]. 37 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 38 - pub struct Unspecified; 39 - 40 - impl core::fmt::Display for Unspecified { 41 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 42 - f.write_str("Unspecified") 43 - } 44 - } 45 - 46 - impl core::error::Error for Unspecified {} 47 - 48 - #[derive(Clone, Debug, PartialEq, Eq)] 49 - pub struct KeyRejected(std::borrow::Cow<'static, str>); 50 - 51 - impl KeyRejected { 52 - pub fn new(message: impl Into<std::borrow::Cow<'static, str>>) -> Self { 53 - Self(message.into()) 54 - } 55 - } 56 - 57 - impl core::fmt::Display for KeyRejected { 58 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 59 - write!(f, "Verification key rejected: {}", self.0) 60 - } 61 - } 62 - 63 - impl core::error::Error for KeyRejected {} 64 - 65 - mod impl_verification_method { 66 - //! Implement [`IntoVerificationKey`] for [`identity::VerificationMethod`]. 67 - 68 - use aws_lc_rs::signature::{ 69 - ECDSA_P256_SHA256_FIXED, ECDSA_P256K1_SHA256_FIXED, ParsedPublicKey, 70 - }; 71 - use exn::{Exn, ResultExt}; 72 - 73 - use crate::jwt::Algorithm; 74 - 75 - use super::{IntoVerificationKey, KeyRejected, Unspecified, VerificationKey}; 76 - 77 - /// A verfication key derived from an Atmosphere verification method. 78 - #[derive(Debug)] 79 - pub struct VerificationMethodKey { 80 - parsed_public_key: ParsedPublicKey, 81 - algorithm: Algorithm, 82 - } 83 - 84 - impl VerificationKey for VerificationMethodKey { 85 - fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 86 - self.parsed_public_key 87 - .verify_sig(message, signature) 88 - .map_err(|_| Unspecified) 89 - } 90 - 91 - fn algorithm(&self) -> Option<&Algorithm> { 92 - Some(&self.algorithm) 93 - } 94 - } 95 - 96 - impl IntoVerificationKey for identity::VerificationMethod { 97 - type Output = VerificationMethodKey; 98 - 99 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 100 - match self { 101 - Self::Multikey { 102 - id: _, 103 - controller: _, 104 - public_key_multibase, 105 - } => { 106 - let make_error = || { 107 - KeyRejected::new("Failed to decode key from VerificationMethod::Multikey") 108 - }; 109 - 110 - let (_, key_data) = 111 - multibase::decode(public_key_multibase).or_raise(make_error)?; 112 - 113 - let (alg, algorithm, key_data) = match key_data.split_at_checked(2) { 114 - Some(([0xe7, 0x01], key_data)) => { 115 - (&ECDSA_P256K1_SHA256_FIXED, Algorithm::ES256K, key_data) 116 - } 117 - Some(([0x80, 0x24], key_data)) => { 118 - (&ECDSA_P256_SHA256_FIXED, Algorithm::ES256, key_data) 119 - } 120 - _ => { 121 - exn::bail!(KeyRejected::new( 122 - "unsupported multicodec prefix in verification method", 123 - )) 124 - } 125 - }; 126 - 127 - Ok(Self::Output { 128 - parsed_public_key: ParsedPublicKey::new(alg, key_data) 129 - .or_raise(make_error)?, 130 - algorithm, 131 - }) 132 - } 133 - } 134 - } 135 - } 136 - 137 - impl From<multibase::Error> for KeyRejected { 138 - fn from(value: multibase::Error) -> Self { 139 - Self(value.to_string().into()) 140 - } 141 - } 142 - 143 - impl From<aws_lc_rs::error::KeyRejected> for KeyRejected { 144 - fn from(value: aws_lc_rs::error::KeyRejected) -> Self { 145 - Self(value.to_string().into()) 146 - } 147 - } 148 - 149 - impl From<&'static str> for KeyRejected { 150 - fn from(value: &'static str) -> Self { 151 - Self(value.into()) 152 - } 153 - } 154 - } 155 - 156 - mod impl_openssh_keys { 157 - //! Implement [`IntoVerificationKey`] for OpenSSH public keys. 158 - 159 - use core::fmt; 160 - use std::borrow::Cow; 161 - 162 - use aws_lc_rs::{ 163 - encoding::AsDer, 164 - rsa::{PublicEncryptingKey, PublicKeyComponents}, 165 - signature::{self, ParsedPublicKey, VerificationAlgorithm}, 166 - }; 167 - use exn::{Exn, OptionExt, ResultExt}; 168 - 169 - use super::{IntoVerificationKey, KeyRejected, VerificationKey}; 170 - 171 - impl VerificationKey for ParsedPublicKey { 172 - fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), super::Unspecified> { 173 - Self::verify_sig(self, message, signature).map_err(|_| super::Unspecified) 174 - } 175 - } 176 - 177 - pub struct OpenSshKey<T>(pub T); 178 - 179 - impl<T> IntoVerificationKey for OpenSshKey<T> 180 - where 181 - T: AsRef<str>, 182 - { 183 - type Output = ParsedPublicKey; 184 - 185 - fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 186 - use data_encoding::BASE64 as Encoding; 187 - 188 - let make_error = || KeyRejected::new("Failed to parse openssh public key"); 189 - 190 - let key_str = self.0.as_ref(); 191 - let mut parts = key_str.split_ascii_whitespace(); 192 - let maybe_key_type = parts.next(); 193 - let maybe_key_data = parts.next().map(|s| Encoding.decode(s.as_bytes())); 194 - 195 - let (key_type, bytes) = match (maybe_key_type, &maybe_key_data) { 196 - (Some("ssh-ed25519"), Some(Ok(bytes))) => { 197 - let (key_type, remaining) = prefixed_bytes(bytes).or_raise(make_error)?; 198 - require_match(key_type, b"ssh-ed25519")?; 199 - 200 - let (key_data, remaining) = prefixed_bytes(remaining).or_raise(make_error)?; 201 - require_match(remaining.len(), 0)?; 202 - 203 - ("ssh-ed25519", Cow::Borrowed(key_data)) 204 - } 205 - (Some("ecdsa-sha2-nistp256"), Some(Ok(bytes))) => { 206 - let (key_type, remaining) = prefixed_bytes(bytes)?; 207 - require_match(key_type, b"ecdsa-sha2-nistp256")?; 208 - 209 - let (thing, remaining) = prefixed_bytes(remaining)?; 210 - require_match(thing, b"nistp256")?; 211 - 212 - let (key_data, remaining) = prefixed_bytes(remaining)?; 213 - require_match(remaining.len(), 0)?; 214 - 215 - ("ecdsa-sha2-nistp256", Cow::Borrowed(key_data)) 216 - } 217 - (Some("ecdsa-sha2-nistp384"), Some(Ok(bytes))) => { 218 - let (key_type, remaining) = prefixed_bytes(bytes)?; 219 - require_match(key_type, b"ecdsa-sha2-nistp384")?; 220 - 221 - let (thing, remaining) = prefixed_bytes(remaining)?; 222 - require_match(thing, b"nistp384")?; 223 - 224 - let (key_data, remaining) = prefixed_bytes(remaining)?; 225 - require_match(remaining.len(), 0)?; 226 - 227 - ("ecdsa-sha2-nistp384", Cow::Borrowed(key_data)) 228 - } 229 - (Some("ecdsa-sha2-nistp521"), Some(Ok(bytes))) => { 230 - let (key_type, remaining) = prefixed_bytes(bytes)?; 231 - require_match(key_type, b"ecdsa-sha2-nistp521")?; 232 - 233 - let (thing, remaining) = prefixed_bytes(remaining)?; 234 - require_match(thing, b"nistp521")?; 235 - 236 - let (key_data, remaining) = prefixed_bytes(remaining)?; 237 - require_match(remaining.len(), 0)?; 238 - 239 - ("ecdsa-sha2-nistp521", Cow::Borrowed(key_data)) 240 - } 241 - (Some("ssh-rsa"), Some(Ok(bytes))) => { 242 - let (key_type, remaining) = prefixed_bytes(bytes)?; 243 - require_match(key_type, b"ssh-rsa")?; 244 - 245 - let (exponent, remaining) = prefixed_bytes(remaining)?; 246 - 247 - let (mut modulus, remaining) = prefixed_bytes(remaining)?; 248 - require_match(remaining.len(), 0)?; 249 - 250 - while modulus.first().is_some_and(|&val| val == 0) { 251 - modulus = &modulus[1..]; 252 - } 253 - 254 - let key_components = PublicKeyComponents { 255 - e: exponent, 256 - n: modulus, 257 - }; 258 - 259 - let key: PublicEncryptingKey = key_components.try_into().unwrap(); 260 - let serialized = key.as_der().unwrap(); 261 - 262 - ("rsa-sha2-512", Cow::Owned(serialized.as_ref().to_vec())) 263 - } 264 - (Some(_), Some(Err(_))) => { 265 - exn::bail!(KeyRejected::new("Failed to decode base64-encoded key data".to_string())); 266 - } 267 - (Some(key_type), _) => { 268 - exn::bail!(KeyRejected::new(format!( 269 - "unsupported key type: {key_type}" 270 - ))); 271 - } 272 - _ => exn::bail!(KeyRejected::new("invalid format for openssh key")), 273 - }; 274 - 275 - let algorithm: &dyn VerificationAlgorithm = match key_type { 276 - "ecdsa-sha2-nistp256" => &signature::ECDSA_P256_SHA256_FIXED, 277 - "ecdsa-sha2-nistp384" => &signature::ECDSA_P384_SHA384_FIXED, 278 - "ecdsa-sha2-nistp521" => &signature::ECDSA_P521_SHA512_FIXED, 279 - "rsa-sha2-256" => &signature::RSA_PKCS1_2048_8192_SHA256, 280 - "rsa-sha2-512" => &signature::RSA_PKCS1_2048_8192_SHA512, 281 - "ssh-rsa" => &signature::RSA_PKCS1_2048_8192_SHA256, 282 - "ssh-ed25519" => &signature::ED25519, 283 - _ => unreachable!("key decoding should have filtered unknown key types"), 284 - }; 285 - 286 - let parsed_public_key = ParsedPublicKey::new(algorithm, bytes).or_raise(make_error)?; 287 - Ok(parsed_public_key) 288 - } 289 - } 290 - 291 - fn prefixed_bytes(bytes: &[u8]) -> Result<(&[u8], &[u8]), Exn<KeyRejected>> { 292 - let (prefix, data) = bytes 293 - .split_at_checked(size_of::<u32>()) 294 - .ok_or_raise(|| KeyRejected::new("Unexpected end of input"))?; 295 - 296 - let len = u32::from_be_bytes( 297 - prefix 298 - .try_into() 299 - .expect("prefix should have correct length"), 300 - ); 301 - 302 - let len: Result<usize, _> = len.try_into(); 303 - data.split_at_checked( 304 - len.or_raise(|| KeyRejected::new("Prefixed buffer exceeds u32::MAX"))?, 305 - ) 306 - .ok_or_raise(|| KeyRejected::new("Unexpected end of input")) 307 - } 308 - 309 - fn require_match<T: PartialEq + fmt::Debug>(a: T, b: T) -> Result<(), Exn<KeyRejected>> { 310 - exn::ensure!(a == b, KeyRejected::new(format!("{a:?} != {b:?}"))); 311 - Ok(()) 312 - } 313 - } 314 - 315 - pub use impl_openssh_keys::OpenSshKey; 316 - 317 - use crate::jwt::Algorithm; 318 - 319 - #[cfg(test)] 320 - mod tests { 321 - use aws_lc_rs::signature::ParsedPublicKey; 322 - 323 - use crate::verification_key::impl_openssh_keys::OpenSshKey; 324 - 325 - use super::*; 326 - 327 - #[test] 328 - fn can_parse_multikey() { 329 - let verification_method = identity::VerificationMethod::Multikey { 330 - id: "did:plc:65gha4t3avpfpzmvpbwovss7#atproto".to_string(), 331 - controller: "did:plc:65gha4t3avpfpzmvpbwovss7" 332 - .try_into() 333 - .expect("hardcoded did should be valid"), 334 - public_key_multibase: "zQ3shNWn4uG62Nv3dkggV5dGiN7bHK2w2tX2QxtKpVCvDK4Ff".to_string(), 335 - }; 336 - 337 - let _ = verification_method.into_verification_key().unwrap(); 338 - } 339 - 340 - #[test] 341 - fn is_dyn_compatible() { 342 - #[allow(unused)] 343 - fn verify(vk: &dyn VerificationKey, message: &[u8], signature: &[u8]) -> bool { 344 - vk.verify_sig(message, signature).is_ok() 345 - } 346 - 347 - // Can be boxed. 348 - let _vks: Vec<Box<dyn IntoVerificationKey<Output = ParsedPublicKey>>> = Vec::new(); 349 - } 350 - 351 - #[test] 352 - fn can_parse_ssh() { 353 - const ED25519_PUB: &str = 354 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINAIYaoz4/1EB5jTSGAPGX/ycIqYg36Zeu1yKhDYCDT+"; 355 - const ECDSA256_PUB: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBNjmN1noKXdJdVOuqvGHGD/0xLFW349MM1L/dBcm7l+XNjoqne+p43K+3DzqdNyuAY3HqVFuWp3SJv4nudHEIc="; 356 - const ECDSA384_PUB: &str = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBHODcK9/akFH4nzsZIurz0Xr+xMgftsWG/MQ2Ej1yU5kIzV4Uo3yWozv9zn04b6l7pobPGRkje6r/RtTmEviSiVpN5Hcoj/rMlZK15esmvKSIOsgqZnwFFGQtFIc+dcFmA=="; 357 - const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCkooYf+stdQbID0A81kPhUxiIcos0WUXpuxb9Kbfonz+U9HMF5lsHjdi+ocXD33ch9eEXNX9F0wSaPFcImpp4Pz7BKOVIAJ1tp61OTU4SB0z8XAB5omX7EmxdJXYjqBjHuZ3R3FArlU3jwN2OuRWr/A1sC7NY26fzueRHsuzwMNQTM6NsUPXgL6KElsJX7XRuTHY3NLERQKSdyCuDqMlN0GTKtG/ZLcnmil6sccquBjNUekF4skrezBko2UxP9lpg1Qu//iRJUwloQqJwNUZYsbieZ92zGRTia0B/bN5ihQDl7cMuk744AhPAxWlhVl0TVo9Q+BRBdXPOxElNdRj08Y03EJC3LGLBro2TElOLqYDRVpX78mGyUF8mtC+cid98Hdn2sMRoepD2ozkjELmqj5LlAqIy19cZ1x4+VnaS9RRN+3aCrxPTuhK2o1ZH/tVuN+Lu8LDySVnXxFQ4Iy3Uh2Q5IVqQFt+wugQ2dHi6GK7EjGkcEyWFsDjYFoMPgNzc= tjh@macbook-air.local"; 358 - 359 - for sample_key in [ED25519_PUB, ECDSA256_PUB, ECDSA384_PUB, RSA] { 360 - let _ = OpenSshKey(sample_key).into_verification_key().unwrap(); 361 - } 362 - } 363 - }
+3 -3
crates/credential-helper/Cargo.toml
··· 8 8 publish.workspace = true 9 9 10 10 [dependencies] 11 - atproto.workspace = true 12 - auth.workspace = true 13 - identity.workspace = true 11 + gordian-types.workspace = true 12 + gordian-auth.workspace = true 13 + gordian-identity.workspace = true 14 14 15 15 anyhow.workspace = true 16 16 axum.workspace = true
+6 -3
crates/credential-helper/src/commands/git_credential.rs
··· 1 1 use anyhow::Context as _; 2 - use auth::jwt::{Algorithm, Curve, Header}; 3 2 use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD}; 3 + use gordian_auth::jwt::{Algorithm, Curve, Header}; 4 4 use owo_colors::{OwoColorize, Stream::Stderr}; 5 5 use ssh_agent_client_rs::{Client, Identity}; 6 6 use ssh_key::public::{EcdsaPublicKey, KeyData}; ··· 99 99 account_did.if_supports_color(Stderr, |text| text.green()) 100 100 ); 101 101 102 - assert!(self.method.contains(&"ssh".to_string()), "unsupported method"); 102 + assert!( 103 + self.method.contains(&"ssh".to_string()), 104 + "unsupported method" 105 + ); 103 106 104 107 // Build a list of the public keys associated with the current active DID. 105 108 let empty = HashSet::new(); ··· 146 143 let jti: [u8; 16] = rand::random(); 147 144 let jti: Box<str> = BASE32HEX_NOPAD.encode(&jti).to_ascii_lowercase().into(); 148 145 149 - let claims = auth::jwt::Claims { 146 + let claims = gordian_auth::jwt::Claims { 150 147 iss, 151 148 aud, 152 149 iat,
+1 -1
crates/credential-helper/src/config.rs
··· 1 - use atproto::did::OwnedDid; 2 1 use directories::ProjectDirs; 2 + use gordian_types::OwnedDid; 3 3 use serde::{Deserialize, Serialize}; 4 4 use std::{ 5 5 collections::{HashMap, HashSet},
+22
crates/gordian-auth/Cargo.toml
··· 1 + [package] 2 + name = "gordian-auth" 3 + version.workspace = true 4 + authors.workspace = true 5 + repository.workspace = true 6 + license.workspace = true 7 + edition.workspace = true 8 + publish.workspace = true 9 + 10 + [dependencies] 11 + gordian-types = { workspace = true, features = ["serde"] } 12 + gordian-identity = { workspace = true } 13 + 14 + serde.workspace = true 15 + serde_json = { workspace = true, features = ["preserve_order"] } 16 + thiserror.workspace = true 17 + url.workspace = true 18 + 19 + aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 20 + data-encoding = "2.9.0" 21 + multibase = "0.9.1" 22 + exn = "0.3.0"
+257
crates/gordian-auth/src/jwt.rs
··· 1 + use core::fmt; 2 + 3 + use data_encoding::BASE64URL_NOPAD as Encoding; 4 + use gordian_types::{Nsid, OwnedDid}; 5 + use serde::{Deserialize, Serialize, de::DeserializeOwned}; 6 + 7 + use crate::verification_key::{Unspecified, VerificationKey}; 8 + 9 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 10 + pub enum Type { 11 + JWT, 12 + } 13 + 14 + /// Signature algorithm. 15 + /// 16 + /// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt> 17 + #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] 18 + #[non_exhaustive] 19 + pub enum Algorithm { 20 + ES256K, 21 + ES256, 22 + ES384, 23 + ES512, 24 + EdDSA, 25 + } 26 + 27 + impl fmt::Display for Algorithm { 28 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 + fmt::Debug::fmt(self, f) 30 + } 31 + } 32 + 33 + /// See: <https://docs.rs/jose-jwk/latest/jose_jwk/enum.OkpCurves.html> 34 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 35 + #[non_exhaustive] 36 + pub enum Curve { 37 + Ed25519, 38 + } 39 + 40 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 41 + pub struct Header { 42 + pub typ: Type, 43 + 44 + /// Signing-key algorithm. 45 + pub alg: Algorithm, 46 + 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + pub crv: Option<Curve>, 49 + } 50 + 51 + impl Header { 52 + #[must_use] 53 + pub const fn new(alg: Algorithm, crv: Option<Curve>) -> Self { 54 + Self { 55 + typ: Type::JWT, 56 + alg, 57 + crv, 58 + } 59 + } 60 + } 61 + 62 + /// Standard claims for inter-service authentication (JWT). 63 + /// 64 + /// See: <https://atproto.com/specs/xrpc#inter-service-authentication-jwt> 65 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 66 + pub struct Claims { 67 + /// Account DID associated with the service that the request is being 68 + /// sent to. 69 + pub iss: OwnedDid, 70 + 71 + /// Service DID associated with the service that the request is being 72 + /// sent to. 73 + pub aud: OwnedDid, 74 + 75 + /// Token creation time as a UNIX timestamp. 76 + pub iat: i64, 77 + 78 + /// Token expiration time as a UNIX timestamp. 79 + pub exp: i64, 80 + 81 + /// Lexicon method in NSID syntax. 82 + #[serde(skip_serializing_if = "Option::is_none")] 83 + pub lxm: Option<Box<Nsid>>, 84 + 85 + /// Nonce. 86 + pub jti: Box<str>, 87 + } 88 + 89 + #[derive(Debug, Deserialize, Serialize)] 90 + pub struct Token<C = Claims> { 91 + pub header: Header, 92 + pub claims: C, 93 + } 94 + 95 + impl Token<Claims> { 96 + #[inline] 97 + pub fn decode( 98 + token: impl AsRef<[u8]>, 99 + public_key: &dyn VerificationKey, 100 + ) -> Result<Self, Error> { 101 + decode(token, public_key) 102 + } 103 + 104 + #[inline] 105 + pub fn decode_unverified(token: impl AsRef<[u8]>) -> Result<Self, Error> { 106 + decode_unverified(token) 107 + } 108 + } 109 + 110 + #[derive(Debug, thiserror::Error)] 111 + pub enum Error { 112 + #[error("Invalid token format")] 113 + InvalidFormat, 114 + #[error("Invalid token encoding: {0}")] 115 + InvalidEncoding(#[from] data_encoding::DecodeError), 116 + #[error("Failed to parse token: {0}")] 117 + InvalidStructure(#[from] serde_json::Error), 118 + #[error("Serialized header or claims do not match input")] 119 + SerializationMismatch, 120 + #[error("Unsupported signature algorithm '{0}'")] 121 + UnsupportedAlgorithm(Algorithm), 122 + #[error("Signature failure")] 123 + SignatureFailed(#[from] Unspecified), 124 + } 125 + 126 + type TokenParts<'a> = (&'a [u8], &'a [u8], &'a [u8]); 127 + 128 + fn split_token(bytes: &[u8]) -> Result<TokenParts<'_>, Error> { 129 + let mut parts = bytes.split(|bytes| bytes == &b'.'); 130 + match (parts.next(), parts.next(), parts.next(), parts.next()) { 131 + (Some(header), Some(claims), Some(signature), None) => Ok((header, claims, signature)), 132 + _ => Err(Error::InvalidFormat), 133 + } 134 + } 135 + 136 + fn parse<T: DeserializeOwned + Serialize>(encoded_bytes: &[u8]) -> Result<T, Error> { 137 + let bytes = Encoding.decode(encoded_bytes)?; 138 + 139 + // Verify the deserialized input matches the decoded bytes when re-serialized. 140 + let value: serde_json::Value = serde_json::from_slice(&bytes)?; 141 + let serialized = serde_json::to_vec(&value)?; 142 + if bytes != serialized { 143 + return Err(Error::SerializationMismatch); 144 + } 145 + 146 + Ok(serde_json::from_value(value)?) 147 + } 148 + 149 + /// Get the deserialized [`Token`] without verifying the `token`'s signature. 150 + /// 151 + pub fn decode_unverified<C: DeserializeOwned + Serialize>( 152 + token: impl AsRef<[u8]>, 153 + ) -> Result<Token<C>, Error> { 154 + let (header, claims, _) = split_token(token.as_ref())?; 155 + 156 + let header = parse(header)?; 157 + let claims = parse(claims)?; 158 + 159 + Ok(Token { header, claims }) 160 + } 161 + 162 + /// Verify the JWT signature using `verification_key` and return the deserialized [`Token`]. 163 + /// 164 + pub fn decode<C: DeserializeOwned + Serialize>( 165 + token: impl AsRef<[u8]>, 166 + verification_key: &dyn VerificationKey, 167 + ) -> Result<Token<C>, Error> { 168 + let token = token.as_ref(); 169 + let (header, claims, signature) = split_token(token)?; 170 + 171 + // The message to verify is the base64 encoded header and claims section. 172 + let message = &token[..=header.len() + claims.len()]; 173 + let header: Header = parse(header)?; 174 + 175 + if verification_key 176 + .algorithm() 177 + .is_some_and(|&alg| alg != header.alg) 178 + { 179 + return Err(Error::UnsupportedAlgorithm(header.alg)); 180 + } 181 + 182 + // Decode the signature bytes and verify. 183 + let signature = Encoding.decode(signature)?; 184 + verification_key.verify_sig(message, &signature)?; 185 + 186 + let claims = parse(claims)?; 187 + 188 + Ok(Token { header, claims }) 189 + } 190 + 191 + #[cfg(test)] 192 + mod tests { 193 + use gordian_identity::VerificationMethod; 194 + use gordian_types::Did; 195 + 196 + use super::{Algorithm, Error, Token, Type}; 197 + 198 + #[test] 199 + fn can_split_token() { 200 + use super::split_token; 201 + 202 + assert!(split_token(b"").is_err()); 203 + assert!(split_token(b"header").is_err()); 204 + assert!(split_token(b"header.claims").is_err()); 205 + 206 + let (h, c, s) = split_token(b"header.claims.signature").unwrap(); 207 + assert_eq!(h, b"header"); 208 + assert_eq!(c, b"claims"); 209 + assert_eq!(s, b"signature"); 210 + } 211 + 212 + const TOKEN: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NTg2NjE4ODUsImlzcyI6ImRpZDpwbGM6NjVnaGE0dDNhdnBmcHptdnBid292c3M3IiwiYXVkIjoiZGlkOndlYjpnb3JkaWFuLWRldjo1NTU1IiwiZXhwIjoxNzU4NjYxOTQ1LCJseG0iOiJzaC50YW5nbGVkLnJlcG8uY3JlYXRlIiwianRpIjoiY2Y0ZDE5YTIwNDE0YWMzMjk2NTI3NzBkYzIzYjUzNTYifQ.llMTh_dC72uV3A9STs8yTFAo8jO9XUJnK-m8eA4wZ0EZXeLpxQn3oviFH22eh9_SEKtj9y0YXCfWCafVJre8qg"; 213 + 214 + #[test] 215 + fn can_decode_token() { 216 + let Token { header, claims } = Token::decode_unverified(TOKEN).unwrap(); 217 + 218 + assert_eq!(header.typ, Type::JWT); 219 + assert_eq!(header.alg, Algorithm::ES256K); 220 + assert_eq!( 221 + claims.aud.as_ref(), 222 + Did::from_static("did:web:gordian-dev:5555") 223 + ); 224 + assert_eq!( 225 + claims.iss.as_ref(), 226 + Did::from_static("did:plc:65gha4t3avpfpzmvpbwovss7") 227 + ); 228 + } 229 + 230 + #[test] 231 + fn parse_rejects_junk_after_struct() { 232 + let claims = r#"{"iss":"did:plc:65gha4t3avpfpzmvpbwovss7","aud":"did:web:gordian.incus","iat":0,"exp":10,"jti":"totally_random_bytes"} "#; 233 + let encoded = super::Encoding.encode(claims.as_bytes()); 234 + assert!(matches!( 235 + super::parse::<super::Claims>(encoded.as_bytes()), 236 + Err(Error::SerializationMismatch) 237 + )); 238 + } 239 + 240 + #[test] 241 + fn can_verify_token() { 242 + let vm = VerificationMethod::Multikey { 243 + id: "".to_string(), 244 + controller: "did:web:test".try_into().unwrap(), 245 + public_key_multibase: "zQ3shNWn4uG62Nv3dkggV5dGiN7bHK2w2tX2QxtKpVCvDK4Ff".to_string(), 246 + }; 247 + 248 + let Token { header, claims } = Token::decode(TOKEN, &vm).unwrap(); 249 + 250 + assert_eq!(header.typ, Type::JWT); 251 + assert_eq!(header.alg, Algorithm::ES256K); 252 + assert_eq!( 253 + claims.aud.as_ref(), 254 + Did::from_static("did:web:gordian-dev:5555") 255 + ); 256 + } 257 + }
+365
crates/gordian-auth/src/verification_key.rs
··· 1 + use exn::Exn; 2 + 3 + pub trait VerificationKey: Send { 4 + /// Use the verification key to verify that `signature` is a valid signature of 5 + /// `message`. 6 + fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified>; 7 + 8 + fn algorithm(&self) -> Option<&Algorithm> { 9 + None 10 + } 11 + } 12 + 13 + pub trait IntoVerificationKey { 14 + type Output: VerificationKey; 15 + 16 + fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>>; 17 + } 18 + 19 + impl VerificationKey for Box<dyn VerificationKey> { 20 + fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 21 + (**self).verify_sig(message, signature) 22 + } 23 + } 24 + 25 + impl<T, K> VerificationKey for T 26 + where 27 + T: IntoVerificationKey<Output = K> + Send, 28 + K: VerificationKey, 29 + { 30 + fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 31 + let key = self.into_verification_key().map_err(|_| Unspecified)?; 32 + key.verify_sig(message, signature) 33 + } 34 + } 35 + 36 + /// Replicates [`aws_lc_rc::errors::Unspecified`]. 37 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 38 + pub struct Unspecified; 39 + 40 + impl core::fmt::Display for Unspecified { 41 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 42 + f.write_str("Unspecified") 43 + } 44 + } 45 + 46 + impl core::error::Error for Unspecified {} 47 + 48 + #[derive(Clone, Debug, PartialEq, Eq)] 49 + pub struct KeyRejected(std::borrow::Cow<'static, str>); 50 + 51 + impl KeyRejected { 52 + pub fn new(message: impl Into<std::borrow::Cow<'static, str>>) -> Self { 53 + Self(message.into()) 54 + } 55 + } 56 + 57 + impl core::fmt::Display for KeyRejected { 58 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 59 + write!(f, "Verification key rejected: {}", self.0) 60 + } 61 + } 62 + 63 + impl core::error::Error for KeyRejected {} 64 + 65 + mod impl_verification_method { 66 + //! Implement [`IntoVerificationKey`] for [`identity::VerificationMethod`]. 67 + 68 + use aws_lc_rs::signature::{ 69 + ECDSA_P256_SHA256_FIXED, ECDSA_P256K1_SHA256_FIXED, ParsedPublicKey, 70 + }; 71 + use exn::{Exn, ResultExt}; 72 + 73 + use crate::jwt::Algorithm; 74 + 75 + use super::{IntoVerificationKey, KeyRejected, Unspecified, VerificationKey}; 76 + 77 + /// A verfication key derived from an Atmosphere verification method. 78 + #[derive(Debug)] 79 + pub struct VerificationMethodKey { 80 + parsed_public_key: ParsedPublicKey, 81 + algorithm: Algorithm, 82 + } 83 + 84 + impl VerificationKey for VerificationMethodKey { 85 + fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), Unspecified> { 86 + self.parsed_public_key 87 + .verify_sig(message, signature) 88 + .map_err(|_| Unspecified) 89 + } 90 + 91 + fn algorithm(&self) -> Option<&Algorithm> { 92 + Some(&self.algorithm) 93 + } 94 + } 95 + 96 + impl IntoVerificationKey for gordian_identity::VerificationMethod { 97 + type Output = VerificationMethodKey; 98 + 99 + fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 100 + match self { 101 + Self::Multikey { 102 + id: _, 103 + controller: _, 104 + public_key_multibase, 105 + } => { 106 + let make_error = || { 107 + KeyRejected::new("Failed to decode key from VerificationMethod::Multikey") 108 + }; 109 + 110 + let (_, key_data) = 111 + multibase::decode(public_key_multibase).or_raise(make_error)?; 112 + 113 + let (alg, algorithm, key_data) = match key_data.split_at_checked(2) { 114 + Some(([0xe7, 0x01], key_data)) => { 115 + (&ECDSA_P256K1_SHA256_FIXED, Algorithm::ES256K, key_data) 116 + } 117 + Some(([0x80, 0x24], key_data)) => { 118 + (&ECDSA_P256_SHA256_FIXED, Algorithm::ES256, key_data) 119 + } 120 + _ => { 121 + exn::bail!(KeyRejected::new( 122 + "unsupported multicodec prefix in verification method", 123 + )) 124 + } 125 + }; 126 + 127 + Ok(Self::Output { 128 + parsed_public_key: ParsedPublicKey::new(alg, key_data) 129 + .or_raise(make_error)?, 130 + algorithm, 131 + }) 132 + } 133 + } 134 + } 135 + } 136 + 137 + impl From<multibase::Error> for KeyRejected { 138 + fn from(value: multibase::Error) -> Self { 139 + Self(value.to_string().into()) 140 + } 141 + } 142 + 143 + impl From<aws_lc_rs::error::KeyRejected> for KeyRejected { 144 + fn from(value: aws_lc_rs::error::KeyRejected) -> Self { 145 + Self(value.to_string().into()) 146 + } 147 + } 148 + 149 + impl From<&'static str> for KeyRejected { 150 + fn from(value: &'static str) -> Self { 151 + Self(value.into()) 152 + } 153 + } 154 + } 155 + 156 + mod impl_openssh_keys { 157 + //! Implement [`IntoVerificationKey`] for OpenSSH public keys. 158 + 159 + use core::fmt; 160 + use std::borrow::Cow; 161 + 162 + use aws_lc_rs::{ 163 + encoding::AsDer, 164 + rsa::{PublicEncryptingKey, PublicKeyComponents}, 165 + signature::{self, ParsedPublicKey, VerificationAlgorithm}, 166 + }; 167 + use exn::{Exn, OptionExt, ResultExt}; 168 + 169 + use super::{IntoVerificationKey, KeyRejected, VerificationKey}; 170 + 171 + impl VerificationKey for ParsedPublicKey { 172 + fn verify_sig(&self, message: &[u8], signature: &[u8]) -> Result<(), super::Unspecified> { 173 + Self::verify_sig(self, message, signature).map_err(|_| super::Unspecified) 174 + } 175 + } 176 + 177 + pub struct OpenSshKey<T>(pub T); 178 + 179 + impl<T> IntoVerificationKey for OpenSshKey<T> 180 + where 181 + T: AsRef<str>, 182 + { 183 + type Output = ParsedPublicKey; 184 + 185 + fn into_verification_key(&self) -> Result<Self::Output, Exn<KeyRejected>> { 186 + use data_encoding::BASE64 as Encoding; 187 + 188 + let make_error = || KeyRejected::new("Failed to parse openssh public key"); 189 + 190 + let key_str = self.0.as_ref(); 191 + let mut parts = key_str.split_ascii_whitespace(); 192 + let maybe_key_type = parts.next(); 193 + let maybe_key_data = parts.next().map(|s| Encoding.decode(s.as_bytes())); 194 + 195 + let (key_type, bytes) = match (maybe_key_type, &maybe_key_data) { 196 + (Some("ssh-ed25519"), Some(Ok(bytes))) => { 197 + let (key_type, remaining) = prefixed_bytes(bytes).or_raise(make_error)?; 198 + require_match(key_type, b"ssh-ed25519")?; 199 + 200 + let (key_data, remaining) = prefixed_bytes(remaining).or_raise(make_error)?; 201 + require_match(remaining.len(), 0)?; 202 + 203 + ("ssh-ed25519", Cow::Borrowed(key_data)) 204 + } 205 + (Some("ecdsa-sha2-nistp256"), Some(Ok(bytes))) => { 206 + let (key_type, remaining) = prefixed_bytes(bytes)?; 207 + require_match(key_type, b"ecdsa-sha2-nistp256")?; 208 + 209 + let (thing, remaining) = prefixed_bytes(remaining)?; 210 + require_match(thing, b"nistp256")?; 211 + 212 + let (key_data, remaining) = prefixed_bytes(remaining)?; 213 + require_match(remaining.len(), 0)?; 214 + 215 + ("ecdsa-sha2-nistp256", Cow::Borrowed(key_data)) 216 + } 217 + (Some("ecdsa-sha2-nistp384"), Some(Ok(bytes))) => { 218 + let (key_type, remaining) = prefixed_bytes(bytes)?; 219 + require_match(key_type, b"ecdsa-sha2-nistp384")?; 220 + 221 + let (thing, remaining) = prefixed_bytes(remaining)?; 222 + require_match(thing, b"nistp384")?; 223 + 224 + let (key_data, remaining) = prefixed_bytes(remaining)?; 225 + require_match(remaining.len(), 0)?; 226 + 227 + ("ecdsa-sha2-nistp384", Cow::Borrowed(key_data)) 228 + } 229 + (Some("ecdsa-sha2-nistp521"), Some(Ok(bytes))) => { 230 + let (key_type, remaining) = prefixed_bytes(bytes)?; 231 + require_match(key_type, b"ecdsa-sha2-nistp521")?; 232 + 233 + let (thing, remaining) = prefixed_bytes(remaining)?; 234 + require_match(thing, b"nistp521")?; 235 + 236 + let (key_data, remaining) = prefixed_bytes(remaining)?; 237 + require_match(remaining.len(), 0)?; 238 + 239 + ("ecdsa-sha2-nistp521", Cow::Borrowed(key_data)) 240 + } 241 + (Some("ssh-rsa"), Some(Ok(bytes))) => { 242 + let (key_type, remaining) = prefixed_bytes(bytes)?; 243 + require_match(key_type, b"ssh-rsa")?; 244 + 245 + let (exponent, remaining) = prefixed_bytes(remaining)?; 246 + 247 + let (mut modulus, remaining) = prefixed_bytes(remaining)?; 248 + require_match(remaining.len(), 0)?; 249 + 250 + while modulus.first().is_some_and(|&val| val == 0) { 251 + modulus = &modulus[1..]; 252 + } 253 + 254 + let key_components = PublicKeyComponents { 255 + e: exponent, 256 + n: modulus, 257 + }; 258 + 259 + let key: PublicEncryptingKey = key_components.try_into().unwrap(); 260 + let serialized = key.as_der().unwrap(); 261 + 262 + ("rsa-sha2-512", Cow::Owned(serialized.as_ref().to_vec())) 263 + } 264 + (Some(_), Some(Err(_))) => { 265 + exn::bail!(KeyRejected::new( 266 + "Failed to decode base64-encoded key data".to_string() 267 + )); 268 + } 269 + (Some(key_type), _) => { 270 + exn::bail!(KeyRejected::new(format!( 271 + "unsupported key type: {key_type}" 272 + ))); 273 + } 274 + _ => exn::bail!(KeyRejected::new("invalid format for openssh key")), 275 + }; 276 + 277 + let algorithm: &dyn VerificationAlgorithm = match key_type { 278 + "ecdsa-sha2-nistp256" => &signature::ECDSA_P256_SHA256_FIXED, 279 + "ecdsa-sha2-nistp384" => &signature::ECDSA_P384_SHA384_FIXED, 280 + "ecdsa-sha2-nistp521" => &signature::ECDSA_P521_SHA512_FIXED, 281 + "rsa-sha2-256" => &signature::RSA_PKCS1_2048_8192_SHA256, 282 + "rsa-sha2-512" => &signature::RSA_PKCS1_2048_8192_SHA512, 283 + "ssh-rsa" => &signature::RSA_PKCS1_2048_8192_SHA256, 284 + "ssh-ed25519" => &signature::ED25519, 285 + _ => unreachable!("key decoding should have filtered unknown key types"), 286 + }; 287 + 288 + let parsed_public_key = ParsedPublicKey::new(algorithm, bytes).or_raise(make_error)?; 289 + Ok(parsed_public_key) 290 + } 291 + } 292 + 293 + fn prefixed_bytes(bytes: &[u8]) -> Result<(&[u8], &[u8]), Exn<KeyRejected>> { 294 + let (prefix, data) = bytes 295 + .split_at_checked(size_of::<u32>()) 296 + .ok_or_raise(|| KeyRejected::new("Unexpected end of input"))?; 297 + 298 + let len = u32::from_be_bytes( 299 + prefix 300 + .try_into() 301 + .expect("prefix should have correct length"), 302 + ); 303 + 304 + let len: Result<usize, _> = len.try_into(); 305 + data.split_at_checked( 306 + len.or_raise(|| KeyRejected::new("Prefixed buffer exceeds u32::MAX"))?, 307 + ) 308 + .ok_or_raise(|| KeyRejected::new("Unexpected end of input")) 309 + } 310 + 311 + fn require_match<T: PartialEq + fmt::Debug>(a: T, b: T) -> Result<(), Exn<KeyRejected>> { 312 + exn::ensure!(a == b, KeyRejected::new(format!("{a:?} != {b:?}"))); 313 + Ok(()) 314 + } 315 + } 316 + 317 + pub use impl_openssh_keys::OpenSshKey; 318 + 319 + use crate::jwt::Algorithm; 320 + 321 + #[cfg(test)] 322 + mod tests { 323 + use aws_lc_rs::signature::ParsedPublicKey; 324 + 325 + use crate::verification_key::impl_openssh_keys::OpenSshKey; 326 + 327 + use super::*; 328 + 329 + #[test] 330 + fn can_parse_multikey() { 331 + let verification_method = gordian_identity::VerificationMethod::Multikey { 332 + id: "did:plc:65gha4t3avpfpzmvpbwovss7#atproto".to_string(), 333 + controller: "did:plc:65gha4t3avpfpzmvpbwovss7" 334 + .try_into() 335 + .expect("hardcoded did should be valid"), 336 + public_key_multibase: "zQ3shNWn4uG62Nv3dkggV5dGiN7bHK2w2tX2QxtKpVCvDK4Ff".to_string(), 337 + }; 338 + 339 + let _ = verification_method.into_verification_key().unwrap(); 340 + } 341 + 342 + #[test] 343 + fn is_dyn_compatible() { 344 + #[allow(unused)] 345 + fn verify(vk: &dyn VerificationKey, message: &[u8], signature: &[u8]) -> bool { 346 + vk.verify_sig(message, signature).is_ok() 347 + } 348 + 349 + // Can be boxed. 350 + let _vks: Vec<Box<dyn IntoVerificationKey<Output = ParsedPublicKey>>> = Vec::new(); 351 + } 352 + 353 + #[test] 354 + fn can_parse_ssh() { 355 + const ED25519_PUB: &str = 356 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINAIYaoz4/1EB5jTSGAPGX/ycIqYg36Zeu1yKhDYCDT+"; 357 + const ECDSA256_PUB: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBNjmN1noKXdJdVOuqvGHGD/0xLFW349MM1L/dBcm7l+XNjoqne+p43K+3DzqdNyuAY3HqVFuWp3SJv4nudHEIc="; 358 + const ECDSA384_PUB: &str = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBHODcK9/akFH4nzsZIurz0Xr+xMgftsWG/MQ2Ej1yU5kIzV4Uo3yWozv9zn04b6l7pobPGRkje6r/RtTmEviSiVpN5Hcoj/rMlZK15esmvKSIOsgqZnwFFGQtFIc+dcFmA=="; 359 + const RSA: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCkooYf+stdQbID0A81kPhUxiIcos0WUXpuxb9Kbfonz+U9HMF5lsHjdi+ocXD33ch9eEXNX9F0wSaPFcImpp4Pz7BKOVIAJ1tp61OTU4SB0z8XAB5omX7EmxdJXYjqBjHuZ3R3FArlU3jwN2OuRWr/A1sC7NY26fzueRHsuzwMNQTM6NsUPXgL6KElsJX7XRuTHY3NLERQKSdyCuDqMlN0GTKtG/ZLcnmil6sccquBjNUekF4skrezBko2UxP9lpg1Qu//iRJUwloQqJwNUZYsbieZ92zGRTia0B/bN5ihQDl7cMuk744AhPAxWlhVl0TVo9Q+BRBdXPOxElNdRj08Y03EJC3LGLBro2TElOLqYDRVpX78mGyUF8mtC+cid98Hdn2sMRoepD2ozkjELmqj5LlAqIy19cZ1x4+VnaS9RRN+3aCrxPTuhK2o1ZH/tVuN+Lu8LDySVnXxFQ4Iy3Uh2Q5IVqQFt+wugQ2dHi6GK7EjGkcEyWFsDjYFoMPgNzc= tjh@macbook-air.local"; 360 + 361 + for sample_key in [ED25519_PUB, ECDSA256_PUB, ECDSA384_PUB, RSA] { 362 + let _ = OpenSshKey(sample_key).into_verification_key().unwrap(); 363 + } 364 + } 365 + }
+29
crates/gordian-identity/Cargo.toml
··· 1 + [package] 2 + name = "gordian-identity" 3 + version.workspace = true 4 + authors.workspace = true 5 + repository.workspace = true 6 + license.workspace = true 7 + edition.workspace = true 8 + publish.workspace = true 9 + 10 + [dependencies] 11 + gordian-types = { workspace = true, features = ["serde"] } 12 + 13 + reqwest.workspace = true 14 + serde.workspace = true 15 + serde_json.workspace = true 16 + thiserror.workspace = true 17 + tracing.workspace = true 18 + url.workspace = true 19 + 20 + futures-util = "0.3.31" 21 + hickory-resolver = "0.25.2" 22 + moka = { version = "0.12.11", features = ["future"] } 23 + tokio = { version = "1.47.1", default-features = false, features = ["macros"] } 24 + tracing-subscriber = { version = "0.3.20", optional = true } 25 + 26 + [[bin]] 27 + name = "resolve" 28 + path = "src/bin/resolve.rs" 29 + required-features = ["tracing-subscriber", "tokio/rt"]
+85
crates/gordian-identity/src/document.rs
··· 1 + use gordian_types::did::OwnedDid; 2 + use serde::{Deserialize, Serialize}; 3 + use url::Url; 4 + 5 + #[derive(Clone, Debug, Deserialize, Serialize)] 6 + #[serde(tag = "type", rename_all = "PascalCase")] 7 + pub enum VerificationMethod { 8 + #[serde(rename_all = "camelCase")] 9 + Multikey { 10 + id: String, 11 + controller: OwnedDid, 12 + public_key_multibase: String, 13 + }, 14 + } 15 + 16 + #[derive(Clone, Debug, Deserialize, Serialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct Service { 19 + pub id: String, 20 + #[serde(rename = "type")] 21 + pub typ: String, 22 + pub service_endpoint: Url, 23 + } 24 + 25 + impl Service { 26 + /// Create a [`Service`] definition for an `ATproto` PDS from `service_endpoint`. 27 + #[must_use] 28 + pub fn atproto_pds(service_endpoint: Url) -> Self { 29 + Self { 30 + id: "#atproto_pds".to_string(), 31 + typ: "AtprotoPersonalDataServer".to_string(), 32 + service_endpoint, 33 + } 34 + } 35 + } 36 + 37 + #[derive(Clone, Debug, Deserialize, Serialize)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct DidDocument { 40 + #[serde(default, rename = "@context")] 41 + pub context: Vec<Url>, 42 + pub id: OwnedDid, 43 + pub also_known_as: Vec<Url>, 44 + pub verification_method: Vec<VerificationMethod>, 45 + pub service: Vec<Service>, 46 + } 47 + 48 + impl DidDocument { 49 + pub fn new(id: &str, handle: &str) -> Result<Self, gordian_types::did::Error> { 50 + let id = id.parse()?; 51 + Ok(Self { 52 + context: Default::default(), 53 + id, 54 + also_known_as: vec![Url::parse(&format!("at://{handle}")).expect("valid handle")], 55 + verification_method: Default::default(), 56 + service: Default::default(), 57 + }) 58 + } 59 + 60 + #[must_use] 61 + pub fn primary_alias(&self) -> Option<&str> { 62 + self.also_known_as 63 + .first() 64 + .and_then(|at_uri| at_uri.domain()) 65 + } 66 + 67 + #[must_use] 68 + pub fn atproto_pds(&self) -> Option<&Service> { 69 + self.service.iter().find(|service| { 70 + service.id == "#atproto_pds" && service.typ == "AtprotoPersonalDataServer" 71 + }) 72 + } 73 + } 74 + 75 + #[cfg(test)] 76 + mod tests { 77 + use super::DidDocument; 78 + 79 + #[test] 80 + fn init_doc() { 81 + let doc = DidDocument::new("did:plc:65gha4t3avpfpzmvpbwovss7", "tjh.dev") 82 + .expect("valid did document"); 83 + assert_eq!(doc.primary_alias(), Some("tjh.dev")); 84 + } 85 + }
+330
crates/gordian-identity/src/lib.rs
··· 1 + mod document; 2 + pub mod resolvers; 3 + 4 + use core::fmt; 5 + use std::sync::Arc; 6 + 7 + use futures_util::{FutureExt as _, future::BoxFuture}; 8 + use gordian_types::did::OwnedDid; 9 + 10 + pub use document::{DidDocument, Service, VerificationMethod}; 11 + pub use gordian_types::did::Did; 12 + 13 + use crate::resolvers::mock::MockResolver; 14 + 15 + pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory"; 16 + 17 + pub type HttpClient = reqwest::Client; 18 + pub type HttpError = reqwest::Error; 19 + 20 + pub trait ResolveIdentity: fmt::Debug + Sync { 21 + /// Resolve a handle or a DID to a DID and DID document. 22 + /// 23 + fn resolve<'s: 'a, 'a>( 24 + &'s self, 25 + ident: &'a str, 26 + ) -> BoxFuture<'a, Result<(OwnedDid, DidDocument), ResolveError>> { 27 + if let Ok(did) = ident.parse::<OwnedDid>() { 28 + async { 29 + let doc = self.resolve_did(&did).await?; 30 + let handle = doc.primary_alias().ok_or(ResolveError::InvalidDocument)?; 31 + 32 + // Verify the primary handle in the DID document resolves to 33 + // the DID we were given. 34 + let resolved = self.resolve_handle(handle).await?; 35 + if did == resolved { 36 + Ok((did, doc)) 37 + } else { 38 + Err(ResolveError::BidirectionalFailure) 39 + } 40 + } 41 + .boxed() 42 + } else { 43 + let handle = ident.trim_start_matches('@'); 44 + async move { 45 + let did = self.resolve_handle(handle).await?; 46 + let doc = self.resolve_did(&did).await?; 47 + 48 + // Verify the document has a matching handle. 49 + for alias in &doc.also_known_as { 50 + if alias.domain().is_some_and(|host| host == ident) { 51 + return Ok((did, doc)); 52 + } 53 + } 54 + 55 + Err(ResolveError::BidirectionalFailure) 56 + } 57 + .boxed() 58 + } 59 + } 60 + 61 + /// Resolve a handle to a DID. 62 + /// 63 + /// Implementors are not required to bi-directionally confirm the resolution. 64 + /// 65 + /// [`ResolveIdentity::resolve`] should be preferred. 66 + /// 67 + /// Related: <https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle> 68 + fn resolve_handle<'s: 'h, 'h>( 69 + &'s self, 70 + handle: &'h str, 71 + ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>>; 72 + 73 + /// Resolve a DID to DID document. 74 + /// 75 + /// Implementors are not required to bi-directionally confirm the resolution. 76 + /// 77 + /// [`ResolveIdentity::resolve`] should be preferred. 78 + /// 79 + /// Ref: <https://docs.bsky.app/docs/api/com-atproto-identity-resolve-did> 80 + fn resolve_did<'s: 'd, 'd>( 81 + &'s self, 82 + did: &'d Did, 83 + ) -> BoxFuture<'d, Result<DidDocument, ResolveError>>; 84 + 85 + /// Instruct a caching resolver to remove any cached resolutions for the 86 + /// specified DID. 87 + /// 88 + /// This will have no effect on system-level caches, like DNS caches. 89 + /// 90 + fn invalidate_did<'s: 'd, 'd>(&'s self, _: &'d Did) -> BoxFuture<'d, ()> { 91 + async {}.boxed() 92 + } 93 + } 94 + 95 + #[derive(Clone, Debug, thiserror::Error)] 96 + pub enum ResolveError { 97 + #[error("Failed to resolve handle")] 98 + UnresolvedHandle, 99 + #[error("DID method not supported: {0}")] 100 + UnsupportedDidMethod(String), 101 + #[error("Failed to resolve DID document: {0}")] 102 + Document(Arc<HttpError>), 103 + #[error("Bidirectional resolution failed")] 104 + BidirectionalFailure, 105 + #[error("Handle is invalid")] 106 + InvalidHandle, 107 + #[error("DID document is invalid")] 108 + InvalidDocument, 109 + } 110 + 111 + impl From<HttpError> for ResolveError { 112 + fn from(value: HttpError) -> Self { 113 + Self::Document(Arc::new(value)) 114 + } 115 + } 116 + 117 + #[derive(Clone, Debug)] 118 + pub struct Resolver { 119 + inner: std::sync::Arc<dyn ResolveIdentity + Sync + Send + 'static>, 120 + } 121 + 122 + impl Resolver { 123 + pub fn new<R>(resolver: R) -> Self 124 + where 125 + R: ResolveIdentity + Send + 'static, 126 + { 127 + let inner = Arc::new(resolver); 128 + Self { inner } 129 + } 130 + 131 + #[must_use] 132 + pub fn builder() -> ResolverBuilder { 133 + ResolverBuilder::new() 134 + } 135 + 136 + /// Resolve a handle or a DID to a DID document. 137 + pub async fn resolve(&self, ident: &str) -> Result<(OwnedDid, DidDocument), ResolveError> { 138 + let ident = ident.trim_start_matches('@'); 139 + self.inner.resolve(ident).await 140 + } 141 + 142 + /// Resolve a handle to a DID. 143 + #[inline] 144 + pub async fn resolve_handle(&self, handle: &str) -> Result<OwnedDid, ResolveError> { 145 + let handle = handle.trim_start_matches('@'); 146 + self.inner.resolve_handle(handle).await 147 + } 148 + 149 + /// Resolve a DID to a DID document. 150 + #[inline] 151 + pub async fn resolve_did(&self, did: &Did) -> Result<DidDocument, ResolveError> { 152 + self.inner.resolve_did(did).await 153 + } 154 + 155 + #[inline] 156 + pub async fn invalidate_did(&self, did: &Did) { 157 + self.inner.invalidate_did(did).await; 158 + } 159 + } 160 + 161 + impl Resolver { 162 + /// Create a mock resolver, which only resolves the specified identities from 163 + /// memory. 164 + /// 165 + /// The underlying [`MockResolver`] is returned along with the type-erased [`Resolver`] to 166 + /// enable access to [`MockResolver::insert`]. 167 + /// 168 + pub fn mocked(documents: impl IntoIterator<Item = DidDocument>) -> (Self, MockResolver) { 169 + let resolver = MockResolver::new(documents); 170 + let mocked = resolver.clone(); 171 + let inner = Arc::new(resolver); 172 + (Self { inner }, mocked) 173 + } 174 + } 175 + 176 + impl ResolveIdentity for Resolver { 177 + fn resolve<'s: 'i, 'i>( 178 + &'s self, 179 + ident: &'i str, 180 + ) -> BoxFuture<'i, Result<(OwnedDid, DidDocument), ResolveError>> { 181 + Self::resolve(self, ident).boxed() 182 + } 183 + 184 + fn resolve_handle<'s: 'h, 'h>( 185 + &'s self, 186 + handle: &'h str, 187 + ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 188 + Self::resolve_handle(self, handle).boxed() 189 + } 190 + 191 + fn resolve_did<'s: 'd, 'd>( 192 + &'s self, 193 + did: &'d Did, 194 + ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 195 + Self::resolve_did(self, did).boxed() 196 + } 197 + 198 + fn invalidate_did<'s: 'd, 'd>(&'s self, did: &'d Did) -> BoxFuture<'d, ()> { 199 + Self::invalidate_did(self, did).boxed() 200 + } 201 + } 202 + 203 + pub struct ResolverBuilder { 204 + backend: ResolverBackend, 205 + plc_directory: std::borrow::Cow<'static, str>, 206 + cache_capacity: u64, 207 + cache_ttl: std::time::Duration, 208 + } 209 + 210 + impl ResolverBuilder { 211 + #[must_use] 212 + pub fn new() -> Self { 213 + Self { 214 + backend: Default::default(), 215 + plc_directory: DEFAULT_PLC_DIRECTORY.into(), 216 + cache_capacity: 1000, 217 + cache_ttl: std::time::Duration::from_secs(1000), 218 + } 219 + } 220 + 221 + /// Use [`DirectResolver`] as the backend resolver. 222 + /// 223 + /// This resolver does not cache DIDs or DID documents. 224 + #[must_use] 225 + pub const fn direct(mut self) -> Self { 226 + self.backend = ResolverBackend::Direct; 227 + self 228 + } 229 + 230 + /// Use [`MemcacheResolver`] as the backend resolver. 231 + #[must_use] 232 + pub const fn memcache(mut self) -> Self { 233 + self.backend = ResolverBackend::Memcache; 234 + self 235 + } 236 + 237 + /// Set the cache capacity for both DIDs and DID documents. 238 + /// 239 + /// Ignored if backend resolver does not support caching. 240 + #[must_use] 241 + pub const fn cache_capacity(mut self, cap: u64) -> Self { 242 + self.cache_capacity = cap; 243 + self 244 + } 245 + 246 + /// Set the cache Time-To-Live for both DIDs and DID documents. 247 + /// 248 + /// Ignored if backend resolver does not support caching. 249 + #[must_use] 250 + pub const fn cache_ttl(mut self, ttl: std::time::Duration) -> Self { 251 + self.cache_ttl = ttl; 252 + self 253 + } 254 + 255 + pub fn plc_directory( 256 + mut self, 257 + plc_directory: impl Into<std::borrow::Cow<'static, str>>, 258 + ) -> Self { 259 + self.plc_directory = plc_directory.into(); 260 + self 261 + } 262 + 263 + #[must_use] 264 + pub fn build_with(self, http: HttpClient) -> Resolver { 265 + use resolvers::direct::DirectResolver; 266 + use resolvers::memcache::MemcacheResolver; 267 + use std::sync::Arc; 268 + 269 + let inner: Arc<dyn ResolveIdentity + Send + Sync + 'static> = match self.backend { 270 + ResolverBackend::Direct => Arc::new( 271 + DirectResolver::builder() 272 + .plc_directory(self.plc_directory) 273 + .build_with(http), 274 + ), 275 + ResolverBackend::Memcache => { 276 + let resolver = DirectResolver::builder() 277 + .plc_directory(self.plc_directory) 278 + .build_with(http); 279 + Arc::new( 280 + MemcacheResolver::builder() 281 + .capacity(self.cache_capacity) 282 + .ttl(self.cache_ttl) 283 + .build_with(resolver), 284 + ) 285 + } 286 + }; 287 + 288 + Resolver { inner } 289 + } 290 + } 291 + 292 + impl Default for ResolverBuilder { 293 + fn default() -> Self { 294 + Self::new() 295 + } 296 + } 297 + 298 + #[derive(Default)] 299 + enum ResolverBackend { 300 + Direct, 301 + #[default] 302 + Memcache, 303 + } 304 + 305 + #[cfg(test)] 306 + mod tests { 307 + use super::*; 308 + 309 + #[tokio::test] 310 + async fn empty_mock_resolver() { 311 + let (resolver, _) = Resolver::mocked([]); 312 + assert!(matches!( 313 + resolver.resolve("tjh.dev").await, 314 + Err(ResolveError::UnresolvedHandle) 315 + )); 316 + } 317 + 318 + #[tokio::test] 319 + async fn mock_resolver() { 320 + let (resolver, _) = 321 + Resolver::mocked([ 322 + DidDocument::new("did:plc:65gha4t3avpfpzmvpbwovss7", "tjh.dev") 323 + .expect("valid did document"), 324 + ]); 325 + 326 + let (did, doc) = resolver.resolve("tjh.dev").await.expect("resolved id"); 327 + assert_eq!(did.as_ref(), "did:plc:65gha4t3avpfpzmvpbwovss7"); 328 + assert_eq!(doc.primary_alias(), Some("tjh.dev")); 329 + } 330 + }
+194
crates/gordian-identity/src/resolvers/direct.rs
··· 1 + use std::borrow::Cow; 2 + 3 + use futures_util::{FutureExt as _, future::BoxFuture}; 4 + use gordian_types::did::OwnedDid; 5 + use hickory_resolver::name_server::TokioConnectionProvider; 6 + use hickory_resolver::{ 7 + ResolveError as DnsResolveError, Resolver as DnsClient, TokioResolver, 8 + name_server::ConnectionProvider, 9 + }; 10 + use tokio::time::Instant; 11 + 12 + use crate::{DEFAULT_PLC_DIRECTORY, Did, DidDocument, HttpClient, ResolveError, ResolveIdentity}; 13 + 14 + pub struct DirectResolver<'plc, R: ConnectionProvider> { 15 + plc: Cow<'plc, str>, 16 + dns: DnsClient<R>, 17 + http: HttpClient, 18 + } 19 + 20 + impl<R: ConnectionProvider> DirectResolver<'_, R> { 21 + async fn fetch_plc_did_document(&self, did: &Did) -> Result<DidDocument, reqwest::Error> { 22 + self.http 23 + .get(format!("{}/{did}", self.plc)) 24 + .send() 25 + .await? 26 + .error_for_status()? 27 + .json() 28 + .await 29 + } 30 + 31 + async fn fetch_web_did_document(&self, did: &Did) -> Result<DidDocument, reqwest::Error> { 32 + self.http 33 + .get(format!("https://{}/.well-known/did.json", did.ident())) 34 + .send() 35 + .await? 36 + .error_for_status()? 37 + .json() 38 + .await 39 + } 40 + } 41 + 42 + impl<R: ConnectionProvider> ResolveIdentity for DirectResolver<'_, R> { 43 + fn resolve_handle<'s: 'h, 'h>( 44 + &'s self, 45 + handle: &'h str, 46 + ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 47 + let dns = resolve_handle_dns(&self.dns, handle); 48 + let http = resolve_handle_http(&self.http, handle); 49 + 50 + // @NOTE This impl races the two resolution methods and uses the first 51 + // one to return a valid result (usually DNS). This may be 52 + // technically incorrect. 53 + 54 + let start = Instant::now(); 55 + async move { 56 + tokio::select! { 57 + Ok(Some(did)) = dns => { 58 + tracing::trace!(?handle, %did, elapsed = ?start.elapsed(), "resolved via dns"); 59 + Ok(did) 60 + }, 61 + Ok(Some(did)) = http => { 62 + tracing::trace!(?handle, %did, elapsed = ?start.elapsed(), "resolved via http"); 63 + Ok(did) 64 + } 65 + else => Err(ResolveError::UnresolvedHandle), 66 + } 67 + } 68 + .boxed() 69 + } 70 + 71 + fn resolve_did<'s: 'd, 'd>( 72 + &'s self, 73 + did: &'d Did, 74 + ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 75 + async { 76 + match did.method() { 77 + "plc" => Ok(self.fetch_plc_did_document(did).await?), 78 + "web" => Ok(self.fetch_web_did_document(did).await?), 79 + method => Err(ResolveError::UnsupportedDidMethod(method.to_string())), 80 + } 81 + } 82 + .boxed() 83 + } 84 + } 85 + 86 + impl DirectResolver<'_, TokioConnectionProvider> { 87 + #[must_use] 88 + pub const fn builder() -> DirectResolverBuilder<'static> { 89 + DirectResolverBuilder { 90 + plc_directory: Cow::Borrowed(DEFAULT_PLC_DIRECTORY), 91 + } 92 + } 93 + } 94 + 95 + impl Default for DirectResolver<'_, TokioConnectionProvider> { 96 + fn default() -> Self { 97 + Self { 98 + plc: Cow::Borrowed(DEFAULT_PLC_DIRECTORY), 99 + dns: TokioResolver::builder_tokio() 100 + .expect("Failed to build default DNS resolver") 101 + .build(), 102 + http: HttpClient::new(), 103 + } 104 + } 105 + } 106 + 107 + impl<R: ConnectionProvider> std::fmt::Debug for DirectResolver<'_, R> { 108 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 109 + f.debug_struct("DirectResolver") 110 + .field("directory", &self.plc) 111 + .finish_non_exhaustive() 112 + } 113 + } 114 + 115 + pub struct DirectResolverBuilder<'plc> { 116 + plc_directory: Cow<'plc, str>, 117 + } 118 + 119 + impl<'plc> DirectResolverBuilder<'plc> { 120 + /// Set the PLC directory to use. Must be a well-formed URL. 121 + /// 122 + /// Defaults to "<https://plc.directory>". 123 + #[must_use] 124 + pub fn plc_directory(mut self, directory: Cow<'plc, str>) -> Self { 125 + self.plc_directory = directory; 126 + self 127 + } 128 + 129 + #[must_use] 130 + pub fn build_with(self, http: HttpClient) -> DirectResolver<'plc, TokioConnectionProvider> { 131 + DirectResolver { 132 + plc: self.plc_directory, 133 + dns: TokioResolver::builder_tokio() 134 + .expect("Failed to build default DNS resolver") 135 + .build(), 136 + http, 137 + } 138 + } 139 + } 140 + 141 + pub async fn resolve_handle_dns<R>( 142 + client: &DnsClient<R>, 143 + handle: &str, 144 + ) -> Result<Option<OwnedDid>, DnsResolveError> 145 + where 146 + R: ConnectionProvider, 147 + { 148 + let mut resolved_did = None; 149 + let txt_lookup = client.txt_lookup(format!("_atproto.{handle}.")).await?; 150 + for record in txt_lookup.iter() { 151 + for txt_data in record.txt_data() { 152 + let Ok(txt) = std::str::from_utf8(txt_data) else { 153 + continue; 154 + }; 155 + let Some(txt_did) = txt.strip_prefix("did=") else { 156 + continue; 157 + }; 158 + let Ok(did) = txt_did.parse::<OwnedDid>() else { 159 + continue; 160 + }; 161 + 162 + if let Some(old_did) = resolved_did.replace(did.clone()) 163 + && old_did != did 164 + { 165 + tracing::error!( 166 + ?handle, 167 + ?did, 168 + ?old_did, 169 + "multiple conflicting DIDs found for handle" 170 + ); 171 + // @TODO Replace this with an error so we can retry with a 172 + // recursive dns resolver. 173 + return Ok(None); 174 + } 175 + } 176 + } 177 + 178 + Ok(resolved_did) 179 + } 180 + 181 + pub async fn resolve_handle_http( 182 + client: &HttpClient, 183 + handle: &str, 184 + ) -> Result<Option<OwnedDid>, reqwest::Error> { 185 + let response = client 186 + .get(format!("https://{handle}/.well-known/atproto-did")) 187 + .send() 188 + .await? 189 + .error_for_status()? 190 + .text() 191 + .await?; 192 + 193 + Ok(response.trim().parse().ok()) 194 + }
+153
crates/gordian-identity/src/resolvers/memcache.rs
··· 1 + use std::{sync::Arc, time::Duration}; 2 + 3 + use futures_util::{FutureExt as _, TryFutureExt as _, future::BoxFuture}; 4 + use gordian_types::OwnedDid; 5 + use moka::future::{Cache, CacheBuilder}; 6 + 7 + use crate::{Did, DidDocument, ResolveError, ResolveIdentity}; 8 + 9 + const DEFAULT_DID_CACHE_CAP: u64 = 1024; 10 + const DEFAULT_DOC_CACHE_CAP: u64 = 1024; 11 + 12 + const DEFAULT_DID_CACHE_TTL: Duration = Duration::from_secs(600); 13 + const DEFAULT_DOC_CACHE_TTL: Duration = Duration::from_secs(600); 14 + 15 + /// An indentity resolver with an in-memory cache. 16 + #[derive(Debug)] 17 + pub struct MemcacheResolver { 18 + did_cache: Cache<Box<str>, OwnedDid>, 19 + doc_cache: Cache<OwnedDid, DidDocument>, 20 + inner: Box<dyn ResolveIdentity + Send + 'static>, 21 + } 22 + 23 + impl MemcacheResolver { 24 + /// Create a memory-caching resolver wrapping another resolver. 25 + pub fn wrap<R>(resolver: R) -> Self 26 + where 27 + R: ResolveIdentity + Send + 'static, 28 + { 29 + let did_cache = CacheBuilder::new(DEFAULT_DID_CACHE_CAP) 30 + .time_to_live(DEFAULT_DID_CACHE_TTL) 31 + .build(); 32 + 33 + let doc_cache = CacheBuilder::new(DEFAULT_DOC_CACHE_CAP) 34 + .time_to_live(DEFAULT_DOC_CACHE_TTL) 35 + .build(); 36 + 37 + Self { 38 + did_cache, 39 + doc_cache, 40 + inner: Box::new(resolver), 41 + } 42 + } 43 + 44 + #[must_use] 45 + pub const fn builder() -> MemcacheResolverBuilder { 46 + MemcacheResolverBuilder { 47 + did_cache_capacity: DEFAULT_DID_CACHE_CAP, 48 + did_cache_ttl: DEFAULT_DID_CACHE_TTL, 49 + doc_cache_capacity: DEFAULT_DOC_CACHE_CAP, 50 + doc_cache_ttl: DEFAULT_DOC_CACHE_TTL, 51 + } 52 + } 53 + } 54 + 55 + impl ResolveIdentity for MemcacheResolver { 56 + fn resolve_handle<'s: 'h, 'h>( 57 + &'s self, 58 + handle: &'h str, 59 + ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 60 + self.did_cache 61 + .try_get_with(handle.into(), self.inner.resolve_handle(handle)) 62 + .map_err(Arc::unwrap_or_clone) 63 + .boxed() 64 + } 65 + 66 + fn resolve_did<'s: 'd, 'd>( 67 + &'s self, 68 + did: &'d Did, 69 + ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 70 + self.doc_cache 71 + .try_get_with_by_ref(did, self.inner.resolve_did(did)) 72 + .map_err(Arc::unwrap_or_clone) 73 + .boxed() 74 + } 75 + 76 + fn invalidate_did<'s: 'd, 'd>(&'s self, did: &'d Did) -> BoxFuture<'d, ()> { 77 + async move { 78 + if let Some(doc) = self.doc_cache.remove(did).await { 79 + tracing::trace!(?did, "invalidating DID document"); 80 + for handle in doc.also_known_as.iter().filter_map(|uri| uri.domain()) { 81 + tracing::trace!(?did, ?handle, "invalidating handle from DID"); 82 + self.did_cache.remove(handle).await; 83 + } 84 + } 85 + } 86 + .boxed() 87 + } 88 + } 89 + 90 + pub struct MemcacheResolverBuilder { 91 + did_cache_capacity: u64, 92 + did_cache_ttl: Duration, 93 + 94 + doc_cache_capacity: u64, 95 + doc_cache_ttl: Duration, 96 + } 97 + 98 + impl MemcacheResolverBuilder { 99 + /// Set the DID cache capacity and the DID document cache capacity. 100 + #[must_use] 101 + pub const fn capacity(self, capacity: u64) -> Self { 102 + self.did_capacity(capacity).doc_capacity(capacity) 103 + } 104 + 105 + /// Set the DID cache ttl and the DID document cache ttl. 106 + #[must_use] 107 + pub const fn ttl(self, ttl: Duration) -> Self { 108 + self.did_ttl(ttl).doc_ttl(ttl) 109 + } 110 + 111 + #[must_use] 112 + pub const fn did_capacity(mut self, capacity: u64) -> Self { 113 + self.did_cache_capacity = capacity; 114 + self 115 + } 116 + 117 + #[must_use] 118 + pub const fn doc_capacity(mut self, capacity: u64) -> Self { 119 + self.doc_cache_capacity = capacity; 120 + self 121 + } 122 + 123 + #[must_use] 124 + pub const fn did_ttl(mut self, ttl: Duration) -> Self { 125 + self.did_cache_ttl = ttl; 126 + self 127 + } 128 + 129 + #[must_use] 130 + pub const fn doc_ttl(mut self, ttl: Duration) -> Self { 131 + self.doc_cache_ttl = ttl; 132 + self 133 + } 134 + 135 + pub fn build_with<R>(self, resolver: R) -> MemcacheResolver 136 + where 137 + R: ResolveIdentity + Send + 'static, 138 + { 139 + let did_cache = CacheBuilder::new(self.did_cache_capacity) 140 + .time_to_live(self.did_cache_ttl) 141 + .build(); 142 + 143 + let doc_cache = CacheBuilder::new(self.doc_cache_capacity) 144 + .time_to_live(self.doc_cache_ttl) 145 + .build(); 146 + 147 + MemcacheResolver { 148 + did_cache, 149 + doc_cache, 150 + inner: Box::new(resolver), 151 + } 152 + } 153 + }
+93
crates/gordian-identity/src/resolvers/mock.rs
··· 1 + use std::{ 2 + collections::HashMap, 3 + sync::{Arc, Mutex}, 4 + }; 5 + 6 + use futures_util::{FutureExt, future::BoxFuture}; 7 + use gordian_types::OwnedDid; 8 + 9 + use crate::{DidDocument, ResolveError, ResolveIdentity}; 10 + 11 + #[derive(Debug, Default)] 12 + struct Inner { 13 + handle_did: HashMap<String, OwnedDid>, 14 + did_document: HashMap<OwnedDid, DidDocument>, 15 + } 16 + 17 + #[derive(Clone, Debug, Default)] 18 + pub struct MockResolver { 19 + inner: Arc<Mutex<Inner>>, 20 + } 21 + 22 + impl MockResolver { 23 + pub fn new(documents: impl IntoIterator<Item = DidDocument>) -> Self { 24 + let mut handle_did = HashMap::default(); 25 + let mut did_document = HashMap::default(); 26 + for (handle, did_val, did_key, document) in documents.into_iter().map(split_document) { 27 + handle_did.insert(handle, did_val); 28 + did_document.insert(did_key, document); 29 + } 30 + 31 + let inner = Arc::new(Mutex::new(Inner { 32 + handle_did, 33 + did_document, 34 + })); 35 + 36 + Self { inner } 37 + } 38 + 39 + /// Add an identity to the mock resolver. 40 + pub fn insert(&self, document: DidDocument) { 41 + let (handle, did_val, did_key, document) = split_document(document); 42 + let mut map = self.inner.lock().expect("unpoisoned mutex"); 43 + map.handle_did.insert(handle, did_val); 44 + map.did_document.insert(did_key, document); 45 + } 46 + } 47 + 48 + fn split_document(document: DidDocument) -> (String, OwnedDid, OwnedDid, DidDocument) { 49 + let handle = document 50 + .primary_alias() 51 + .expect("aliased identity") 52 + .to_owned(); 53 + 54 + let did = &document.id; 55 + 56 + (handle, did.to_owned(), did.to_owned(), document) 57 + } 58 + 59 + impl FromIterator<DidDocument> for MockResolver { 60 + fn from_iter<T: IntoIterator<Item = DidDocument>>(iter: T) -> Self { 61 + Self::new(iter) 62 + } 63 + } 64 + 65 + impl ResolveIdentity for MockResolver { 66 + fn resolve_handle<'s: 'h, 'h>( 67 + &'s self, 68 + handle: &'h str, 69 + ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 70 + async move { 71 + let map = self.inner.lock().expect("unpoisoned mutex"); 72 + map.handle_did 73 + .get(handle) 74 + .ok_or(ResolveError::UnresolvedHandle) 75 + .cloned() 76 + } 77 + .boxed() 78 + } 79 + 80 + fn resolve_did<'s: 'd, 'd>( 81 + &'s self, 82 + did: &'d gordian_types::Did, 83 + ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 84 + async move { 85 + let map = self.inner.lock().expect("unpoisoned mutex"); 86 + map.did_document 87 + .get(did) 88 + .ok_or(ResolveError::UnresolvedHandle) 89 + .cloned() 90 + } 91 + .boxed() 92 + } 93 + }
+38
crates/gordian-jetstream/Cargo.toml
··· 1 + [package] 2 + name = "gordian-jetstream" 3 + version.workspace = true 4 + authors.workspace = true 5 + repository.workspace = true 6 + license.workspace = true 7 + edition.workspace = true 8 + publish.workspace = true 9 + 10 + [dependencies] 11 + gordian-types = { workspace = true, features = ["serde"] } 12 + 13 + serde.workspace = true 14 + serde_json.workspace = true 15 + time.workspace = true 16 + tracing.workspace = true 17 + url.workspace = true 18 + 19 + bytes = "1.10.1" 20 + flume = "0.11.1" 21 + futures-util = "0.3.31" 22 + tokio = { version = "1.48.0", features = ["macros", "rt", "sync", "time"] } 23 + tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } 24 + tokio-util = "0.7.17" 25 + tracing-subscriber = "0.3.20" 26 + 27 + zstd = { version = "0.13.3", optional = true } 28 + clap = { version = "4.5.50", features = ["derive"], optional = true } 29 + thiserror.workspace = true 30 + fastrand = "2.3.0" 31 + 32 + [features] 33 + default = ["clap", "zstd"] 34 + 35 + [[bin]] 36 + name = "jetstream" 37 + path = "src/main.rs" 38 + required-features = ["clap"]
+222
crates/gordian-jetstream/src/client.rs
··· 1 + use crate::{ 2 + Nsid, 3 + de::Event, 4 + metrics::{Metrics, MetricsData}, 5 + subscriber_options::SubscriberOptions, 6 + task::JetstreamTaskError, 7 + }; 8 + use bytes::Bytes; 9 + use gordian_types::OwnedDid; 10 + use std::sync::{Arc, Mutex}; 11 + use tokio::sync::oneshot; 12 + use tokio_util::sync::{CancellationToken, DropGuard}; 13 + 14 + #[derive(Debug)] 15 + pub struct JetstreamClient { 16 + client_tx: flume::Sender<ClientCommand>, 17 + options: Arc<Mutex<SubscriberOptions>>, 18 + metrics: Metrics, 19 + #[allow(unused)] 20 + shutdown: DropGuard, 21 + } 22 + 23 + impl JetstreamClient { 24 + pub(crate) fn new( 25 + client_tx: flume::Sender<ClientCommand>, 26 + options: Arc<Mutex<SubscriberOptions>>, 27 + metrics: Metrics, 28 + shutdown: CancellationToken, 29 + ) -> Self { 30 + Self { 31 + client_tx, 32 + options, 33 + metrics, 34 + shutdown: shutdown.drop_guard(), 35 + } 36 + } 37 + 38 + /// Add a DID to the Jetstream filters. 39 + pub async fn add_did(&self, did: impl Into<OwnedDid>) -> Result<(), JetstreamClientError> { 40 + if self.options.lock().unwrap().add_did(did.into())? { 41 + // The DID is new to the client, notify the task to update. 42 + self.update_task().await?; 43 + } 44 + Ok(()) 45 + } 46 + 47 + /// Remove a DID from the Jetstream filters. 48 + pub async fn remove_did(&self, did: impl Into<OwnedDid>) -> Result<(), JetstreamClientError> { 49 + if self.options.lock().unwrap().remove_did(&did.into()) { 50 + self.update_task().await?; 51 + } 52 + Ok(()) 53 + } 54 + 55 + /// Add a collection to the Jetstream filters. 56 + pub async fn add_collection( 57 + &self, 58 + collection: impl Into<Box<Nsid>>, 59 + ) -> Result<(), JetstreamClientError> { 60 + if self 61 + .options 62 + .lock() 63 + .unwrap() 64 + .add_collection(collection.into())? 65 + { 66 + // The collection is new to the client, notify the task to update. 67 + self.update_task().await?; 68 + } 69 + Ok(()) 70 + } 71 + 72 + /// Remove a collection from the Jetstream filters. 73 + pub async fn remove_collection( 74 + &self, 75 + collection: impl Into<Box<Nsid>>, 76 + ) -> Result<(), JetstreamClientError> { 77 + if self 78 + .options 79 + .lock() 80 + .unwrap() 81 + .remove_collection(&collection.into()) 82 + { 83 + self.update_task().await?; 84 + } 85 + Ok(()) 86 + } 87 + 88 + #[must_use] 89 + pub fn metrics(&self) -> MetricsData { 90 + self.metrics.export() 91 + } 92 + 93 + pub async fn shutdown(self) -> Result<(), JetstreamClientError> { 94 + let (command, complete) = ClientCommand::shutdown(); 95 + self.client_tx.send(command)?; 96 + complete.await??; 97 + Ok(()) 98 + } 99 + 100 + async fn update_task(&self) -> Result<(), JetstreamClientError> { 101 + let (command, complete) = ClientCommand::subscriber_options_update(); 102 + self.client_tx.send(command)?; 103 + complete.await??; 104 + Ok(()) 105 + } 106 + } 107 + 108 + pub type CommandResponse<T> = oneshot::Sender<Result<T, JetstreamTaskError>>; 109 + 110 + pub enum ClientCommand { 111 + SubscriberOptionsUpdate(CommandResponse<()>), 112 + Shutdown(CommandResponse<()>), 113 + } 114 + 115 + impl ClientCommand { 116 + fn subscriber_options_update() -> (Self, oneshot::Receiver<Result<(), JetstreamTaskError>>) { 117 + let (tx, rx) = oneshot::channel(); 118 + (Self::SubscriberOptionsUpdate(tx), rx) 119 + } 120 + 121 + fn shutdown() -> (Self, oneshot::Receiver<Result<(), JetstreamTaskError>>) { 122 + let (tx, rx) = oneshot::channel(); 123 + (Self::Shutdown(tx), rx) 124 + } 125 + } 126 + 127 + #[derive(Debug, thiserror::Error)] 128 + pub enum JetstreamClientError { 129 + #[error("Client task shutdown")] 130 + TaskShutdown, 131 + #[error("Error in jetstream client task: {0}")] 132 + TaskError(#[from] JetstreamTaskError), 133 + #[error("DID filter exceeds maximum size")] 134 + TooManyDids(OwnedDid), 135 + #[error("Collection filter exceeds maximum size")] 136 + TooManyCollections(Box<Nsid>), 137 + } 138 + 139 + impl<T> From<flume::SendError<T>> for JetstreamClientError { 140 + fn from(_: flume::SendError<T>) -> Self { 141 + Self::TaskShutdown 142 + } 143 + } 144 + 145 + impl From<oneshot::error::RecvError> for JetstreamClientError { 146 + fn from(_: oneshot::error::RecvError) -> Self { 147 + Self::TaskShutdown 148 + } 149 + } 150 + 151 + impl From<OwnedDid> for JetstreamClientError { 152 + fn from(value: OwnedDid) -> Self { 153 + Self::TooManyDids(value) 154 + } 155 + } 156 + 157 + impl From<Box<Nsid>> for JetstreamClientError { 158 + fn from(value: Box<Nsid>) -> Self { 159 + Self::TooManyCollections(value) 160 + } 161 + } 162 + 163 + #[derive(Debug)] 164 + pub struct JetstreamReceiver { 165 + event_rx: flume::Receiver<Bytes>, 166 + } 167 + 168 + impl JetstreamReceiver { 169 + pub(crate) const fn new(event_rx: flume::Receiver<Bytes>) -> Self { 170 + Self { event_rx } 171 + } 172 + 173 + /// Asynchronously receive a Jetstream event. 174 + /// 175 + /// Returns [`None`] when the Jetstream client is shutdown. 176 + pub async fn recv_async(&self) -> Option<JetstreamEvent> { 177 + let bytes = self.event_rx.recv_async().await.ok()?; 178 + Some(JetstreamEvent::new(bytes)) 179 + } 180 + 181 + /// Synchronously receive a Jetstream event. 182 + /// 183 + /// Returns [`None`] when the Jetstream client is shutdown. 184 + #[must_use] 185 + pub fn recv(&self) -> Option<JetstreamEvent> { 186 + let bytes = self.event_rx.recv().ok()?; 187 + Some(JetstreamEvent::new(bytes)) 188 + } 189 + 190 + /// Consume the Jetstream receiver and return the wrapped flume channel receiver. 191 + #[must_use] 192 + pub fn to_inner(self) -> flume::Receiver<Bytes> { 193 + self.event_rx 194 + } 195 + } 196 + 197 + #[derive(Debug)] 198 + pub struct JetstreamEvent { 199 + bytes: Bytes, 200 + } 201 + 202 + impl JetstreamEvent { 203 + pub fn as_bytes(&self) -> &[u8] { 204 + &self.bytes 205 + } 206 + 207 + /// Consume the event, returning the internal [`Bytes`] buffer. 208 + pub fn to_inner(self) -> Bytes { 209 + self.bytes 210 + } 211 + 212 + const fn new(bytes: Bytes) -> Self { 213 + Self { bytes } 214 + } 215 + } 216 + 217 + impl<'a> JetstreamEvent { 218 + pub fn deserialize(&'a self) -> Result<Event<'a>, serde_json::Error> { 219 + let value = serde_json::from_slice(&self.bytes)?; 220 + Ok(value) 221 + } 222 + }
+338
crates/gordian-jetstream/src/de.rs
··· 1 + use gordian_types::Did; 2 + use serde::{Deserialize, Serialize, de::Visitor}; 3 + use serde_json::value::RawValue; 4 + use time::OffsetDateTime; 5 + 6 + // @NOTE 7 + // 8 + // Using `serde_json::value::RawValue` breaks if any part of the deserialization 9 + // features a tagged enum or `#[serde(flatten)]` is used. 10 + // 11 + // To get around this limitation we first deserialize into a less ideal type, 12 + // then manually transform into the types we want. 13 + 14 + #[derive(Debug, Deserialize, Serialize)] 15 + struct InnerEvent<'a> { 16 + #[serde(borrow)] 17 + did: &'a Did, 18 + time_us: i64, 19 + kind: &'a str, 20 + commit: Option<InnerCommit<'a>>, 21 + identity: Option<InnerIdentity<'a>>, 22 + account: Option<InnerAccount<'a>>, 23 + } 24 + 25 + #[derive(Debug, Deserialize, Serialize)] 26 + struct InnerCommit<'a> { 27 + #[serde(borrow)] 28 + rev: &'a str, 29 + operation: &'a str, 30 + collection: &'a str, 31 + rkey: &'a str, 32 + cid: Option<&'a str>, 33 + record: Option<&'a RawValue>, 34 + } 35 + 36 + #[derive(Debug, Deserialize, Serialize)] 37 + struct InnerIdentity<'a> { 38 + #[serde(borrow)] 39 + did: &'a Did, 40 + handle: Option<&'a str>, 41 + seq: i64, 42 + #[serde(with = "time::serde::rfc3339")] 43 + time: OffsetDateTime, 44 + } 45 + 46 + #[derive(Debug, Deserialize, Serialize)] 47 + pub struct InnerAccount<'a> { 48 + #[serde(default)] 49 + pub active: bool, 50 + #[serde(borrow)] 51 + pub did: &'a Did, 52 + pub handle: Option<&'a str>, 53 + #[serde(default)] 54 + pub status: AccountStatus, 55 + pub seq: i64, 56 + #[serde(with = "time::serde::rfc3339")] 57 + pub time: OffsetDateTime, 58 + } 59 + 60 + #[derive(Debug, Default)] 61 + pub enum AccountStatus { 62 + Active, 63 + Takendown, 64 + Suspended, 65 + Deleted, 66 + Deactivated, 67 + Desynchronized, 68 + Other(Box<str>), 69 + #[default] 70 + None, 71 + } 72 + 73 + impl<'de> Deserialize<'de> for AccountStatus { 74 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 75 + where 76 + D: serde::Deserializer<'de>, 77 + { 78 + struct StatusVisitor; 79 + 80 + impl Visitor<'_> for StatusVisitor { 81 + type Value = AccountStatus; 82 + 83 + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 84 + formatter.write_str("Account Status") 85 + } 86 + 87 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 88 + where 89 + E: serde::de::Error, 90 + { 91 + match v { 92 + "active" => Ok(AccountStatus::Active), 93 + "takendown" => Ok(AccountStatus::Takendown), 94 + "suspened" => Ok(AccountStatus::Suspended), 95 + "deleted" => Ok(AccountStatus::Deleted), 96 + "deactivated" => Ok(AccountStatus::Deactivated), 97 + "desynchronized" => Ok(AccountStatus::Desynchronized), 98 + status => { 99 + tracing::warn!(?status, "unexpected account status"); 100 + Ok(AccountStatus::Other(v.into())) 101 + } 102 + } 103 + } 104 + 105 + fn visit_none<E>(self) -> Result<Self::Value, E> 106 + where 107 + E: serde::de::Error, 108 + { 109 + Ok(AccountStatus::None) 110 + } 111 + 112 + fn visit_unit<E>(self) -> Result<Self::Value, E> 113 + where 114 + E: serde::de::Error, 115 + { 116 + Ok(AccountStatus::None) 117 + } 118 + } 119 + 120 + deserializer.deserialize_any(StatusVisitor) 121 + } 122 + } 123 + 124 + impl Serialize for AccountStatus { 125 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 126 + where 127 + S: serde::Serializer, 128 + { 129 + match self { 130 + Self::Active => serializer.serialize_str("active"), 131 + Self::Takendown => serializer.serialize_str("takendown"), 132 + Self::Suspended => serializer.serialize_str("suspended"), 133 + Self::Deleted => serializer.serialize_str("deleted"), 134 + Self::Deactivated => serializer.serialize_str("deactivated"), 135 + Self::Desynchronized => serializer.serialize_str("desynchronized"), 136 + Self::Other(value) => serializer.serialize_str(value), 137 + Self::None => serializer.serialize_none(), 138 + } 139 + } 140 + } 141 + 142 + #[derive(Debug, Deserialize)] 143 + #[serde(try_from = "InnerEvent")] 144 + pub enum Event<'a> { 145 + Commit(#[serde(borrow)] CommitEvent<'a>), 146 + Account(Account<'a>), 147 + Identity(Identity<'a>), 148 + } 149 + 150 + impl<'a> Event<'a> { 151 + #[must_use] 152 + pub const fn did(&'a self) -> &'a Did { 153 + match self { 154 + Self::Commit(commit) => commit.did(), 155 + Self::Account(account) => account.did, 156 + Self::Identity(identity) => identity.did, 157 + } 158 + } 159 + 160 + #[must_use] 161 + pub const fn ts(&self) -> OffsetDateTime { 162 + match self { 163 + Self::Commit(commit) => commit.ts(), 164 + Self::Account(account) => account.ts, 165 + Self::Identity(identity) => identity.ts, 166 + } 167 + } 168 + } 169 + 170 + #[derive(Debug)] 171 + pub enum CommitEvent<'a> { 172 + Create(Commit<'a>), 173 + Update(Commit<'a>), 174 + Delete(Delete<'a>), 175 + } 176 + 177 + impl<'a> CommitEvent<'a> { 178 + #[must_use] 179 + pub const fn ts(&self) -> OffsetDateTime { 180 + match self { 181 + Self::Create(commit) => commit.ts, 182 + Self::Update(commit) => commit.ts, 183 + Self::Delete(commit) => commit.ts, 184 + } 185 + } 186 + 187 + #[must_use] 188 + pub const fn did(&'a self) -> &'a Did { 189 + match self { 190 + Self::Create(commit) => commit.did, 191 + Self::Update(commit) => commit.did, 192 + Self::Delete(commit) => commit.did, 193 + } 194 + } 195 + 196 + #[inline] 197 + #[must_use] 198 + pub const fn did_str(&'a self) -> &'a str { 199 + self.did().as_str() 200 + } 201 + 202 + #[must_use] 203 + pub const fn collection(&self) -> &str { 204 + match self { 205 + Self::Create(commit) => commit.collection, 206 + Self::Update(commit) => commit.collection, 207 + Self::Delete(commit) => commit.collection, 208 + } 209 + } 210 + 211 + #[must_use] 212 + pub const fn rkey(&self) -> &str { 213 + match self { 214 + Self::Create(commit) => commit.rkey, 215 + Self::Update(commit) => commit.rkey, 216 + Self::Delete(commit) => commit.rkey, 217 + } 218 + } 219 + 220 + #[must_use] 221 + pub const fn rev(&self) -> &str { 222 + match self { 223 + Self::Create(commit) => commit.rev, 224 + Self::Update(commit) => commit.rev, 225 + Self::Delete(commit) => commit.rev, 226 + } 227 + } 228 + } 229 + 230 + #[derive(Debug)] 231 + pub struct Commit<'a> { 232 + pub ts: OffsetDateTime, 233 + pub did: &'a Did, 234 + pub collection: &'a str, 235 + pub rkey: &'a str, 236 + pub rev: &'a str, 237 + pub cid: &'a str, 238 + pub record: &'a RawValue, 239 + } 240 + 241 + #[derive(Debug, Deserialize, Serialize)] 242 + pub struct Delete<'a> { 243 + pub ts: OffsetDateTime, 244 + #[serde(borrow)] 245 + pub did: &'a Did, 246 + pub collection: &'a str, 247 + pub rkey: &'a str, 248 + pub rev: &'a str, 249 + } 250 + 251 + #[derive(Debug)] 252 + pub struct Account<'a> { 253 + pub ts: OffsetDateTime, 254 + pub active: bool, 255 + pub did: &'a Did, 256 + pub handle: Option<&'a str>, 257 + pub status: AccountStatus, 258 + pub seq: i64, 259 + pub time: OffsetDateTime, 260 + } 261 + 262 + #[derive(Debug)] 263 + pub struct Identity<'a> { 264 + pub ts: OffsetDateTime, 265 + pub did: &'a Did, 266 + pub handle: Option<&'a str>, 267 + pub seq: i64, 268 + pub time: OffsetDateTime, 269 + } 270 + 271 + impl<'a> TryFrom<InnerEvent<'a>> for Event<'a> { 272 + type Error = &'static str; 273 + 274 + fn try_from(value: InnerEvent<'a>) -> Result<Self, Self::Error> { 275 + let ts = OffsetDateTime::from_unix_timestamp_nanos(i128::from(value.time_us) * 1000) 276 + .map_err(|_| "Failed to parse timestamp")?; 277 + match (value.kind, value.commit, value.account, value.identity) { 278 + ("commit", Some(commit), None, None) => { 279 + match (commit.operation, commit.cid, commit.record) { 280 + ("create", Some(cid), Some(record)) => { 281 + Ok(Self::Commit(CommitEvent::Create(Commit { 282 + ts, 283 + did: value.did, 284 + collection: commit.collection, 285 + rkey: commit.rkey, 286 + rev: commit.rev, 287 + cid, 288 + record, 289 + }))) 290 + } 291 + 292 + ("create", None, _) => Err("missing 'cid' field in commit create"), 293 + ("create", _, None) => Err("missing 'record' field in commit create"), 294 + ("update", Some(cid), Some(record)) => { 295 + Ok(Self::Commit(CommitEvent::Update(Commit { 296 + ts, 297 + did: value.did, 298 + collection: commit.collection, 299 + rkey: commit.rkey, 300 + rev: commit.rev, 301 + cid, 302 + record, 303 + }))) 304 + } 305 + ("update", None, _) => Err("missing 'cid' field in commit update"), 306 + ("update", _, None) => Err("missing 'record' field in commit update"), 307 + ("delete", None, None) => Ok(Self::Commit(CommitEvent::Delete(Delete { 308 + ts, 309 + did: value.did, 310 + collection: commit.collection, 311 + rkey: commit.rkey, 312 + rev: commit.rev, 313 + }))), 314 + _ => Err("unexpected operation"), 315 + } 316 + 317 + // 318 + } 319 + ("account", None, Some(account), None) => Ok(Self::Account(Account { 320 + ts, 321 + active: account.active, 322 + did: account.did, 323 + handle: account.handle, 324 + status: account.status, 325 + seq: account.seq, 326 + time: account.time, 327 + })), 328 + ("identity", None, None, Some(identity)) => Ok(Self::Identity(Identity { 329 + ts, 330 + did: identity.did, 331 + handle: identity.handle, 332 + seq: identity.seq, 333 + time: identity.time, 334 + })), 335 + _ => Err("unexpected event kind"), 336 + } 337 + } 338 + }
+26
crates/gordian-jetstream/src/lib.rs
··· 1 + mod client; 2 + mod de; 3 + mod task; 4 + 5 + pub mod client_config; 6 + pub mod client_options; 7 + pub mod metrics; 8 + pub mod subscriber_options; 9 + 10 + pub use client::{JetstreamClient, JetstreamClientError, JetstreamEvent, JetstreamReceiver}; 11 + pub use de::{AccountStatus, Commit, CommitEvent, Delete, Event, Identity, InnerAccount}; 12 + pub use gordian_types::{Did, Nsid}; 13 + pub use serde_json::Value; 14 + 15 + pub const PUBLIC_JETSTREAM_US_EAST1: &str = "wss://jetstream1.us-east.bsky.network"; 16 + pub const PUBLIC_JETSTREAM_US_EAST2: &str = "wss://jetstream2.us-east.bsky.network"; 17 + pub const PUBLIC_JETSTREAM_US_WEST1: &str = "wss://jetstream1.us-west.bsky.network"; 18 + pub const PUBLIC_JETSTREAM_US_WEST2: &str = "wss://jetstream2.us-west.bsky.network"; 19 + 20 + /// Official public Jetstream instances. 21 + pub const PUBLIC_JETSTREAM_INSTANCES: &[&str] = &[ 22 + PUBLIC_JETSTREAM_US_EAST1, 23 + PUBLIC_JETSTREAM_US_EAST2, 24 + PUBLIC_JETSTREAM_US_WEST1, 25 + PUBLIC_JETSTREAM_US_WEST2, 26 + ];
+95
crates/gordian-jetstream/src/main.rs
··· 1 + mod cli { 2 + use std::path::PathBuf; 3 + 4 + use clap::Parser; 5 + 6 + #[derive(Parser)] 7 + pub struct Arguments { 8 + /// Initial cursor in seconds. 9 + #[arg(long, short = 'C', allow_negative_numbers = true)] 10 + pub cursor: Option<i64>, 11 + 12 + /// Don't print Account events. 13 + #[arg(long)] 14 + pub hide_account: bool, 15 + 16 + /// Don't print Identity events. 17 + #[arg(long)] 18 + pub hide_identity: bool, 19 + 20 + #[arg(long)] 21 + pub log: Option<PathBuf>, 22 + 23 + /// NSIDs or DIDs to filter. 24 + pub filter: Vec<String>, 25 + } 26 + 27 + pub fn parse() -> Arguments { 28 + Parser::parse() 29 + } 30 + } 31 + 32 + use std::{ 33 + fs::File, 34 + io::Write, 35 + time::{Duration, SystemTime, UNIX_EPOCH}, 36 + }; 37 + 38 + use gordian_jetstream::{Event, client_config::JetstreamConfig}; 39 + 40 + #[tokio::main(flavor = "current_thread")] 41 + async fn main() { 42 + tracing_subscriber::fmt::init(); 43 + 44 + let arguments = cli::parse(); 45 + let mut log = arguments 46 + .log 47 + .map(|path| File::options().create(true).append(true).open(path)) 48 + .transpose() 49 + .expect("Failed to open log file"); 50 + 51 + let cursor = match arguments.cursor { 52 + Some(value) if value < 0 => { 53 + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); 54 + let offset = Duration::from_secs(value.unsigned_abs()); 55 + Some(now.checked_sub(offset).unwrap().as_micros()) 56 + } 57 + Some(value) => Some(value.unsigned_abs().into()), 58 + None => None, 59 + }; 60 + 61 + let mut config = JetstreamConfig::default().with_cursor(cursor); 62 + for mut filter in arguments.filter.iter().cloned() { 63 + if let Ok(did) = filter.parse() { 64 + config.subscriber_options.add_did(did).unwrap(); 65 + } else { 66 + if filter.ends_with('.') { 67 + filter.push('*'); 68 + } 69 + config 70 + .subscriber_options 71 + .add_collection(filter.try_into().unwrap()) 72 + .unwrap(); 73 + } 74 + } 75 + 76 + tracing::debug!(?config); 77 + let (client, rx, task) = config.connect(); 78 + 79 + // Spawn the client task. 80 + let handle = tokio::spawn(task); 81 + 82 + while let Some(message) = rx.recv_async().await { 83 + let msg = String::from_utf8_lossy(message.as_bytes()); 84 + if let Some(log) = &mut log { 85 + writeln!(log, "{msg}").expect("Failed to write to log file"); 86 + } 87 + 88 + if let Ok(Event::Commit(commit)) = message.deserialize() { 89 + println!("{commit:#?}"); 90 + eprintln!("{:?}", client.metrics()); 91 + } 92 + } 93 + 94 + handle.await.unwrap(); 95 + }
+318
crates/gordian-jetstream/src/subscriber_options.rs
··· 1 + use std::collections::HashSet; 2 + 3 + use gordian_types::OwnedDid; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + use crate::{Did, Nsid}; 7 + 8 + pub const MAX_WANTED_COLLECTIONS: usize = 100; 9 + 10 + pub const MAX_WANTED_DIDS: usize = 10_000; 11 + 12 + // @TODO Review 13 + pub const MAX_URL_LENGTH: usize = 4000; 14 + 15 + /// Jetstream subscription options. 16 + /// 17 + /// Can either be appended to the `/subscribe` URL on connection to the Jetstream instance 18 + /// or sent as an options update message after connection. 19 + /// 20 + /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#options-updates> 21 + /// 22 + #[derive(Clone, Debug, Default, Deserialize, Serialize)] 23 + #[serde(rename_all = "camelCase")] 24 + pub struct SubscriberOptions { 25 + /// Collection NSIDs to filter which records are received. 26 + /// 27 + /// Maximum: 100 28 + pub wanted_collections: HashSet<Box<Nsid>>, 29 + 30 + /// Repository DIDs to filter which records are received. 31 + /// 32 + /// Maximum: `10_000` 33 + pub wanted_dids: HashSet<OwnedDid>, 34 + 35 + /// Maximum message size in bytes the subscriber wants to receive. 36 + /// 37 + /// Zero means no limit, negative values are treated as zero by Jetstream, and 38 + /// will be normalized to zero when serialized. 39 + #[serde(with = "max_message_size")] 40 + pub max_message_size_bytes: i64, 41 + 42 + pub cursor: Option<u128>, 43 + } 44 + 45 + impl SubscriberOptions { 46 + /// Add a collection NSID to the subscription options. 47 + /// 48 + /// Returns an error if the maximum number of subscribed collections has been reached; `Ok(true)` 49 + /// if the collection was newly added to the set, or `Ok(false)` if the colletion was already in the 50 + /// the set. 51 + pub fn add_collection(&mut self, collection: Box<Nsid>) -> Result<bool, Box<Nsid>> { 52 + if self.wanted_collections.len() == MAX_WANTED_COLLECTIONS 53 + && !self.wanted_collections.contains(&collection) 54 + { 55 + return Err(collection); 56 + } 57 + 58 + Ok(self.wanted_collections.insert(collection)) 59 + } 60 + 61 + pub fn remove_collection(&mut self, collection: &Nsid) -> bool { 62 + self.wanted_collections.remove(collection) 63 + } 64 + 65 + /// Add a DID to the subscription options. 66 + /// 67 + /// Returns an error if the maximum number of subscribed DIDs has been reached; `Ok(true)` 68 + /// if the DID was newly added to the set, or `Ok(false)` if the DID was already in the 69 + /// the set. 70 + pub fn add_did(&mut self, did: OwnedDid) -> Result<bool, OwnedDid> { 71 + if self.wanted_dids.len() == MAX_WANTED_DIDS && !self.wanted_dids.contains(&did) { 72 + return Err(did); 73 + } 74 + 75 + Ok(self.wanted_dids.insert(did)) 76 + } 77 + 78 + pub fn remove_did(&mut self, did: &Did) -> bool { 79 + self.wanted_dids.remove(did) 80 + } 81 + 82 + /// Get the normalized maximum message size. 83 + #[must_use] 84 + pub fn max_message_size(&self) -> i64 { 85 + normalize_max_message_size(self.max_message_size_bytes) 86 + } 87 + 88 + /// Construct the Jetstream subscribe URL, returning a tuple of the URL and a boolean 89 + /// indicating whether the client should send an options update message on connect. 90 + #[must_use] 91 + pub fn subscribe_url(&self, url: &url::Url) -> (url::Url, bool) { 92 + let mut url = url.to_owned(); 93 + url.set_path("/subscribe"); 94 + url.set_query(None); 95 + 96 + if let Some(cursor) = self.cursor { 97 + url.query_pairs_mut() 98 + .append_pair("cursor", &cursor.to_string()); 99 + } 100 + 101 + if self.subscribe_url_len(&url) > MAX_URL_LENGTH { 102 + url.query_pairs_mut().append_pair("requireHello", "true"); 103 + return (url, true); 104 + } 105 + 106 + if !self.wanted_dids.is_empty() || !self.wanted_collections.is_empty() { 107 + let mut query = url.query_pairs_mut(); 108 + for collection in &self.wanted_collections { 109 + query.append_pair("wantedCollections", collection); 110 + } 111 + for did in &self.wanted_dids { 112 + query.append_pair("wantedDids", did.as_str()); 113 + } 114 + } 115 + 116 + if self.max_message_size() > 0 { 117 + url.query_pairs_mut().append_pair( 118 + "maxMessageSizeBytes", 119 + &self.max_message_size_bytes.to_string(), 120 + ); 121 + } 122 + 123 + (url, false) 124 + } 125 + 126 + /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for serialization. 127 + #[must_use] 128 + pub fn as_subscriber_sourced_message(&self) -> SubscriberSourcedMessage<'_> { 129 + SubscriberSourcedMessage::OptionsUpdate(self.into()) 130 + } 131 + 132 + fn subscribe_url_len(&self, base: &url::Url) -> usize { 133 + const WANTED_DIDS_LEN: usize = "wantedDids=".len(); 134 + const WANTED_COLLECTIONS_LEN: usize = "wantedCollections=".len(); 135 + 136 + let (wanted_did_len, wanted_dids_count) = 137 + self.wanted_dids.iter().fold((0, 0), |(len, count), val| { 138 + (len + WANTED_DIDS_LEN + val.len(), count + 1) 139 + }); 140 + 141 + let (wanted_col_len, wanted_col_count) = self 142 + .wanted_collections 143 + .iter() 144 + .fold((0, 0), |(len, count), val| { 145 + (len + WANTED_COLLECTIONS_LEN + val.len(), count + 1) 146 + }); 147 + 148 + let (message_size_len, message_size_count) = match self.max_message_size() { 149 + 0 => (0, 0), 150 + n => (n.to_string().len() + "maxMessageSizeBytes=".len(), 1), 151 + }; 152 + 153 + let param_count = wanted_dids_count + wanted_col_count + message_size_count; 154 + base.as_str().len() + message_size_len + wanted_did_len + wanted_col_len + param_count 155 + } 156 + } 157 + 158 + mod max_message_size { 159 + use serde::{Deserialize, Deserializer, Serializer}; 160 + 161 + pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error> 162 + where 163 + D: Deserializer<'de>, 164 + { 165 + let value = <i64 as Deserialize>::deserialize(deserializer)?; 166 + Ok(super::normalize_max_message_size(value)) 167 + } 168 + 169 + pub fn serialize<S>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> 170 + where 171 + S: Serializer, 172 + { 173 + serializer.serialize_i64(super::normalize_max_message_size(*value)) 174 + } 175 + } 176 + 177 + const fn normalize_max_message_size(value: i64) -> i64 { 178 + value.abs() 179 + } 180 + 181 + /// Subscriber sourced message. 182 + /// 183 + /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#subscriber-sourced-messages> 184 + /// 185 + #[derive(Debug, Serialize)] 186 + #[serde(tag = "type", content = "payload", rename_all = "snake_case")] 187 + pub enum SubscriberSourcedMessage<'a> { 188 + OptionsUpdate(OptionsUpdate<'a>), 189 + } 190 + 191 + impl SubscriberSourcedMessage<'_> { 192 + /// Serialize the [`SubscriberSourcedMessage`] to JSON. 193 + #[must_use] 194 + pub fn to_json(&self) -> String { 195 + serde_json::to_string(self).expect("SubscriberSourcedMessage should be serializable") 196 + } 197 + } 198 + 199 + #[derive(Debug, Serialize)] 200 + #[serde(rename_all = "camelCase")] 201 + pub struct OptionsUpdate<'a> { 202 + wanted_collections: &'a HashSet<Box<Nsid>>, 203 + wanted_dids: &'a HashSet<OwnedDid>, 204 + #[serde(with = "max_message_size")] 205 + max_message_size_bytes: &'a i64, 206 + } 207 + 208 + impl<'a> From<&'a SubscriberOptions> for OptionsUpdate<'a> { 209 + fn from(value: &'a SubscriberOptions) -> Self { 210 + let SubscriberOptions { 211 + wanted_collections, 212 + wanted_dids, 213 + max_message_size_bytes, 214 + cursor: _, 215 + } = value; 216 + Self { 217 + wanted_collections, 218 + wanted_dids, 219 + max_message_size_bytes, 220 + } 221 + } 222 + } 223 + 224 + #[cfg(test)] 225 + mod tests { 226 + use std::collections::HashSet; 227 + 228 + use gordian_types::{Did, Nsid}; 229 + 230 + use super::SubscriberOptions; 231 + 232 + #[test] 233 + fn default() { 234 + let base = "wss://jetstream1.us-east.bsky.network".parse().unwrap(); 235 + let options = SubscriberOptions::default(); 236 + let (url, _) = options.subscribe_url(&base); 237 + assert_eq!( 238 + url.as_str(), 239 + "wss://jetstream1.us-east.bsky.network/subscribe" 240 + ); 241 + } 242 + 243 + #[test] 244 + fn one_collection() { 245 + let base = "wss://jetstream1.us-east.bsky.network".parse().unwrap(); 246 + let mut options = SubscriberOptions::default(); 247 + options 248 + .add_collection(Nsid::from_static("app.bsky.feed.like").into_boxed()) 249 + .unwrap(); 250 + let (url, _) = options.subscribe_url(&base); 251 + assert_eq!( 252 + url.as_str(), 253 + "wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.like" 254 + ); 255 + } 256 + 257 + #[test] 258 + fn query_len() { 259 + let url: url::Url = "wss://example.url/subscribe".parse().unwrap(); 260 + let mut options = SubscriberOptions::default(); 261 + assert_eq!( 262 + options.subscribe_url_len(&url), 263 + "wss://example.url/subscribe".len() 264 + ); 265 + 266 + options 267 + .add_collection(Nsid::from_static("sh.tangled.*").into_boxed()) 268 + .unwrap(); 269 + 270 + assert_eq!( 271 + options.subscribe_url_len(&url), 272 + "wss://example.url/subscribe?wantedCollections=sh.tangled.*".len() 273 + ); 274 + 275 + options 276 + .add_collection(Nsid::from_static("app.bsky.*").into_boxed()) 277 + .unwrap(); 278 + assert_eq!( 279 + options.subscribe_url_len(&url), 280 + "wss://example.url/subscribe?wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*".len() 281 + ); 282 + 283 + options.max_message_size_bytes = 1_000_000; 284 + assert_eq!( 285 + options.subscribe_url_len(&url), 286 + "wss://example.url/subscribe?wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*&maxMessageSizeBytes=1000000".len() 287 + ); 288 + } 289 + 290 + #[test] 291 + fn serialize_default_options() { 292 + let options = SubscriberOptions::default(); 293 + let serialized = options.as_subscriber_sourced_message().to_json(); 294 + assert_eq!( 295 + serialized, 296 + r#"{"type":"options_update","payload":{"wantedCollections":[],"wantedDids":[],"maxMessageSizeBytes":0}}"# 297 + ); 298 + } 299 + 300 + #[test] 301 + fn serialize_example_options() { 302 + let options = SubscriberOptions { 303 + wanted_collections: HashSet::from_iter([ 304 + Nsid::from_static("app.bsky.feed.post").into_boxed() 305 + ]), 306 + wanted_dids: HashSet::from_iter([ 307 + Did::from_static("did:plc:q6gjnaw2blty4crticxkmujt").to_owned() 308 + ]), 309 + max_message_size_bytes: 1000000, 310 + ..Default::default() 311 + }; 312 + let serialized = options.as_subscriber_sourced_message().to_json(); 313 + assert_eq!( 314 + serialized, 315 + r#"{"type":"options_update","payload":{"wantedCollections":["app.bsky.feed.post"],"wantedDids":["did:plc:q6gjnaw2blty4crticxkmujt"],"maxMessageSizeBytes":1000000}}"# 316 + ) 317 + } 318 + }
+65
crates/gordian-knot/Cargo.toml
··· 1 + [package] 2 + name = "gordian-knot" 3 + description = "An alternative Tangled knot-server" 4 + version.workspace = true 5 + authors.workspace = true 6 + repository.workspace = true 7 + license.workspace = true 8 + edition.workspace = true 9 + publish.workspace = true 10 + 11 + [dependencies] 12 + gordian-types = { workspace = true, features = ["sqlx", "time"] } 13 + gordian-auth.workspace = true 14 + gordian-identity.workspace = true 15 + gordian-jetstream.workspace = true 16 + gordian-lexicon.workspace = true 17 + git-service.workspace = true 18 + 19 + anyhow.workspace = true 20 + gix.workspace = true 21 + reqwest.workspace = true 22 + serde.workspace = true 23 + serde_json.workspace = true 24 + thiserror.workspace = true 25 + tracing.workspace = true 26 + url.workspace = true 27 + 28 + aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 29 + axum = { workspace = true, features = ["ws"] } 30 + axum-extra = { version = "0.12.1", features = ["async-read-body"] } 31 + bytes = "1.10.1" 32 + clap = { version = "4.5.47", features = ["derive", "env", "string"] } 33 + data-encoding.workspace = true 34 + futures-util = "0.3.31" 35 + hyper-util = { version = "0.1.17", features = ["client"] } 36 + mimetype-detector = "0.3.4" 37 + moka = { version = "0.12.12", features = ["future"] } 38 + rand = "0.9.2" 39 + rayon = "1.11.0" 40 + rustc-hash = "2.1.1" 41 + time.workspace = true 42 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "time", "json", "macros", "derive"] } 43 + tempfile = "3.24.0" 44 + tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] } 45 + tokio-rayon = "2.1.0" 46 + tokio-stream = { version = "0.1.17", features = ["time"] } 47 + tokio-tungstenite = "0.28.0" 48 + tokio-util = "0.7.18" 49 + tower = { version = "0.5.2", features = ["buffer", "filter", "limit"] } 50 + tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing", "util"] } 51 + tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 52 + dashmap = "6.1.0" 53 + mock-pds = { version = "0.0.0", path = "../mock-pds" } 54 + clap_complete = "4.5.65" 55 + 56 + [dev-dependencies] 57 + http-body-util = "0.1.3" 58 + multibase = "0.9.1" 59 + 60 + [target.'cfg(not(target_env = "msvc"))'.dependencies] 61 + tikv-jemallocator = { version = "0.6.1", optional = true } 62 + 63 + [features] 64 + default = ["jemalloc"] 65 + jemalloc = ["dep:tikv-jemallocator"]
+197
crates/gordian-knot/README.md
··· 1 + # gordian-knot 2 + 3 + A blazingly fast 🚀 and memory-efficient [knot server](https://tangled.org/tangled.org/core/tree/master/knotserver). 4 + 5 + Work in progress. 6 + 7 + ## Status 8 + 9 + ### XRPC API 10 + 11 + | Status | Lexicon Method | Notes | 12 + | :----: | :--------------------------------- | :-- | 13 + | ✅ | `sh.tangled.owner` | | 14 + | ✅ | `sh.tangled.knot.version` | | 15 + | ✅ | `sh.tangled.repo.archive` | | 16 + | ✅ | `sh.tangled.repo.blob` | | 17 + | ✅ | `sh.tangled.repo.branch` | Ignores `shortHash` parameter | 18 + | ✅ | `sh.tangled.repo.branches` | | 19 + | ✅ | `sh.tangled.repo.create` | | 20 + | ✅ | `sh.tangled.repo.delete` | | 21 + | ✅ | `sh.tangled.repo.diff` | | 22 + | ✅ | `sh.tangled.repo.getDefaultBranch` | | 23 + | ✅ | `sh.tangled.repo.mergeCheck` | | 24 + | ✅ | `sh.tangled.repo.log` | | 25 + | ✅ | `sh.tangled.repo.setDefaultBranch` | | 26 + | ✅ | `sh.tangled.repo.tags` | | 27 + | 🔶 | `sh.tangled.repo.compare` | Seems to work, but only computes `patch` field 😅 | 28 + | 🔶 | `sh.tangled.repo.tree` | Cheats by not computing most recent commit 🚀 | 29 + | ❌ | `sh.tangled.repo.deleteBranch` | | 30 + | ❌ | `sh.tangled.repo.forkStatus` | | 31 + | ❌ | `sh.tangled.repo.forkSync` | | 32 + | ❌ | `sh.tangled.repo.hiddenRef` | | 33 + | ❌ | `sh.tangled.repo.merge` | | 34 + | ❌ | `sh.tangled.repo.languages` | | 35 + | ⚫️ | ~~sh.tangled.knot.listKeys~~ | | 36 + 37 + ### Jetstream Ingest 38 + 39 + Complete create/update/delete ingest for collections: 40 + 41 + - [x] `sh.tangled.knot.member` 42 + - [x] `sh.tangled.publicKey` 43 + - [x] `sh.tangled.repo` 44 + - [x] `sh.tangled.repo.collaborator` 45 + 46 + ### `git` Services 47 + 48 + - [x] `git-archive` 49 + - [x] `git-receive-pack` 50 + - [x] `git-upload-pack` 51 + 52 + 53 + ### `/events` 54 + 55 + `sh.tangled.repo.refUpdate` events are generated, but pipelines won’t run because i don’t emit any `sh.tangled.repo.pipeline` events. 56 + 57 + ## Compile 58 + 59 + `cargo` is your friend: 60 + 61 + ```sh 62 + ; cargo build --release --package gordian-knot 63 + ``` 64 + 65 + ## Running 66 + 67 + Serve the knot-server with the `serve` subcommand. All options are configured from the CLI: 68 + 69 + ```sh 70 + ; gordian-knot serve --help 71 + Serve the tangled knot 72 + 73 + Usage: gordian-knot serve [OPTIONS] --name <NAME> --owner <OWNER> 74 + 75 + Options: 76 + -n, --name <NAME> 77 + FQDN of the knot 78 + 79 + [env: KNOT_NAME=] 80 + 81 + -o, --owner <OWNER> 82 + Handle or DID of the knot owner 83 + 84 + [env: KNOT_OWNER=] 85 + 86 + -r, --repos <REPOS> 87 + Base path for repositories 88 + 89 + [env: KNOT_REPO_BASE=] 90 + [default: /home/tjh/gordian] 91 + 92 + -H, --hooks <HOOKS> 93 + Path to knot-level git hooks 94 + 95 + [env: KNOT_HOOKS_PATH=] 96 + 97 + --git-config <GIT_CONFIG> 98 + Path to knot-level git config 99 + 100 + [env: KNOT_GIT_CONFIG_PATH=] 101 + [default: /home/tjh/gordian/git_config] 102 + 103 + --bind <BIND> 104 + Address to bind the the public knot API 105 + 106 + [env: KNOT_ADDR=] 107 + [default: localhost:5555] 108 + 109 + --db <DB> 110 + Path to the knot sqlite database 111 + 112 + [env: KNOT_DATABASE_PATH=] 113 + [default: knot.db] 114 + 115 + --plc-directory <PLC_DIRECTORY> 116 + PLC directory for DID resolution 117 + 118 + [env: KNOT_PLC_DIRECTORY=] 119 + [default: https://plc.directory] 120 + 121 + -j, --jetstream <JETSTREAM> 122 + [env: KNOT_JETSTREAM=] 123 + [default: wss://jetstream1.us-east.bsky.network,wss://jetstream2.us-east.bsky.network,wss://jetstream1.us-west.bsky.network,wss://jetstream2.us-west.bsky.network] 124 + 125 + --require-signed-push=<REQUIRE_SIGNED_PUSH> 126 + Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'. 127 + 128 + See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed> 129 + 130 + [default: true] 131 + [possible values: true, false] 132 + 133 + --repo-cache-size <REPO_CACHE_SIZE> 134 + Number of open repository handles to cache. 135 + 136 + Keeping open handles reduces the overhead of opening a repository at the expense of increased memory usage. 137 + 138 + [env: KNOT_REPO_CACHE_SIZE=] 139 + [default: 0] 140 + 141 + --repo-cache-idle <REPO_CACHE_IDLE> 142 + Seconds to retain an idle repository handle in cache 143 + 144 + [env: KNOT_REPO_CACHE_IDLE=] 145 + [default: 60] 146 + 147 + --repo-cache-live <REPO_CACHE_LIVE> 148 + Seconds to retain a repository handle in cache 149 + 150 + [env: KNOT_REPO_CACHE_LIVE=] 151 + [default: 600] 152 + 153 + --archive-bz2-command <ARCHIVE_BZ2_COMMAND> 154 + Command to use to compress bzip2 archives 155 + 156 + [env: KNOT_ARCHIVE_BZ2=] 157 + [default: /usr/bin/bzip2] 158 + 159 + --archive-xz-command <ARCHIVE_XZ_COMMAND> 160 + Command to use to compress xz archives 161 + 162 + [env: KNOT_ARCHIVE_XZ=] 163 + [default: /usr/bin/xz] 164 + 165 + -h, --help 166 + Print help (see a summary with '-h') 167 + ``` 168 + 169 + The only required options are `--owner` and `--name`. By default the public API is bound to 'localhost:5555' and repositories are stored 170 + in the current working directory. 171 + 172 + ## Differences to the real knot server 173 + 174 + ### Transport 175 + 176 + `ssh` transport for `git` operations is currently unsupported. 177 + 178 + `http` transport is supported for all `git` services (`git fetch`, `git pull`, `git push`, and `git archive`). 179 + 180 + `git push` operations are authorized using signed [service-auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt) 181 + tokens, which may be validated against an atproto signing-key or a supported `sh.tangled.publicKey` for the corresponding identity. 182 + 183 + A `git` credential helper is required to generate such tokens. a slightly dodgy one may be found in [crates/credential-helper](../credential-helper), 184 + which uses `ssh-agent` to create a signed jwt. 185 + 186 + ### Signed `git` Pushes 187 + 188 + `gordian-knot` requires pushes to be accompanied by push certificate signed by a key from a `sh.tangled.publicKey` record associated with the 189 + authorized identity. 190 + 191 + See: 192 + - <https://git-scm.com/docs/git-push#documentation/git-push.txt---signed> 193 + - <https://git-scm.com/docs/githooks/2.27.0#pre-receive> 194 + - <https://git-scm.com/docs/git-receive-pack#_pre_receive_hook> 195 + 196 + This requirement can be disabled by the knot operator with the `--require-signed-push=false` argument. 197 +
+285
crates/gordian-knot/src/cli.rs
··· 1 + use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; 2 + use clap_complete::Shell; 3 + use core::fmt; 4 + use gix::bstr::BString; 5 + use gordian_identity::HttpClient; 6 + use gordian_knot::model::config::{DEFAULT_READMES, KnotConfiguration, RepoCacheConfig}; 7 + use gordian_types::OwnedDid; 8 + use std::{env, path::PathBuf, time::Duration}; 9 + use url::Url; 10 + 11 + pub fn parse() -> KnotCommand { 12 + match Arguments::parse().command { 13 + KnotCommand::Generate(arguments) => { 14 + let mut command = Arguments::command(); 15 + let name = command.get_name().to_string(); 16 + clap_complete::generate(arguments.shell, &mut command, name, &mut std::io::stdout()); 17 + std::process::exit(0); 18 + } 19 + KnotCommand::Serve(mut arguments) => { 20 + if let Some("") = arguments.archive_bz2_command.as_deref() { 21 + arguments.archive_bz2_command = None; 22 + } 23 + 24 + if let Some("") = arguments.archive_xz_command.as_deref() { 25 + arguments.archive_xz_command = None; 26 + } 27 + 28 + KnotCommand::Serve(arguments) 29 + } 30 + hook @ KnotCommand::Hook(_) => hook, 31 + } 32 + } 33 + 34 + #[derive(Debug, Parser)] 35 + #[command(about, author, version)] 36 + pub struct Arguments { 37 + #[clap(subcommand)] 38 + command: KnotCommand, 39 + } 40 + 41 + #[derive(Debug, Subcommand, Clone)] 42 + pub enum KnotCommand { 43 + Generate(GenerateArguments), 44 + Serve(ServeArguments), 45 + Hook(HookArguments), 46 + } 47 + 48 + /// Generate shell completions. 49 + #[derive(Clone, Debug, Args)] 50 + pub struct GenerateArguments { 51 + shell: Shell, 52 + } 53 + 54 + /// Serve the tangled knot. 55 + #[derive(Clone, Debug, Args)] 56 + pub struct ServeArguments { 57 + /// FQDN of the knot. 58 + #[arg(long, short, value_hint = ValueHint::Hostname, env = "KNOT_NAME")] 59 + #[cfg_attr(debug_assertions, arg(default_value = "localhost:5555"))] 60 + pub name: String, 61 + 62 + /// Handle or DID of the knot owner. 63 + #[arg(long, short, env = "KNOT_OWNER")] 64 + pub owner: OwnedDid, 65 + 66 + /// Base path for repositories. 67 + #[arg(long, short, value_hint = ValueHint::DirPath, env = "KNOT_REPO_BASE")] 68 + #[arg(default_value = default_repository_base().into_os_string())] 69 + pub repos: PathBuf, 70 + 71 + /// Path to knot-level git hooks. 72 + #[arg(long, short = 'H', value_hint = ValueHint::DirPath, env = "KNOT_HOOKS_PATH")] 73 + pub hooks: Option<PathBuf>, 74 + 75 + /// Path to knot-level git config. 76 + #[arg(long, value_hint = ValueHint::FilePath, env = "KNOT_GIT_CONFIG_PATH")] 77 + #[arg(default_value = default_repository_base().join("git_config").into_os_string())] 78 + pub git_config: PathBuf, 79 + 80 + /// Address to bind the the public knot API. 81 + #[arg(long, value_delimiter = ',', env = "KNOT_ADDR")] 82 + #[arg(default_value = "localhost:5555")] 83 + pub bind: Vec<String>, 84 + 85 + /// Path to the knot sqlite database. 86 + #[arg(long, env = "KNOT_DATABASE_PATH", default_value = "knot.db")] 87 + pub db: PathBuf, 88 + 89 + /// PLC directory for DID resolution. 90 + #[arg(long, value_hint = ValueHint::Url, env = "KNOT_PLC_DIRECTORY")] 91 + #[arg(default_value = "https://plc.directory")] 92 + pub plc_directory: String, 93 + 94 + #[arg(long, short, value_delimiter = ',', value_hint = ValueHint::Url, env = "KNOT_JETSTREAM")] 95 + #[arg(default_value = default_jetstream_instances())] 96 + pub jetstream: Vec<String>, 97 + 98 + /// Acceptable authorization methods for git pushes over http. 99 + #[arg(hide = true, long, require_equals = true, value_delimiter = ',')] 100 + #[arg(env = "KNOT_AUTH_METHODS")] 101 + #[arg(default_value = "service-auth,public-key")] 102 + pub auth_methods: Vec<AuthenticationMethods>, 103 + 104 + /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'. 105 + /// 106 + /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed> 107 + #[arg(long, action = ArgAction::Set, require_equals = true)] 108 + #[arg(default_value_t = true)] 109 + pub require_signed_push: bool, 110 + 111 + /// Number of open repository handles to cache. 112 + /// 113 + /// Keeping open handles reduces the overhead of opening a repository at the 114 + /// expense of increased memory usage. 115 + #[arg(long, env = "KNOT_REPO_CACHE_SIZE", default_value_t = 0)] 116 + pub repo_cache_size: u64, 117 + 118 + /// Seconds to retain an idle repository handle in cache. 119 + #[arg(long, env = "KNOT_REPO_CACHE_IDLE", default_value_t = 60)] 120 + pub repo_cache_idle: u64, 121 + 122 + /// Seconds to retain a repository handle in cache. 123 + #[arg(long, env = "KNOT_REPO_CACHE_LIVE", default_value_t = 600)] 124 + pub repo_cache_live: u64, 125 + 126 + /// Command to use to compress bzip2 archives. 127 + #[arg(long, env = "KNOT_ARCHIVE_BZ2", default_value = find_command("bzip2").unwrap_or_default())] 128 + pub archive_bz2_command: Option<String>, 129 + 130 + /// Command to use to compress xz archives. 131 + #[arg(long, env = "KNOT_ARCHIVE_XZ", default_value = find_command("xz").unwrap_or_default())] 132 + pub archive_xz_command: Option<String>, 133 + } 134 + 135 + fn find_command(name: &str) -> Option<String> { 136 + use std::process::Command; 137 + 138 + let output = Command::new("which").arg(name).output().ok()?; 139 + if !output.status.success() { 140 + return None; 141 + } 142 + 143 + let full_path = String::from_utf8(output.stdout).ok()?; 144 + Some(full_path.trim().to_string()) 145 + } 146 + 147 + impl ServeArguments { 148 + pub fn to_knot_config(&self) -> Result<KnotConfiguration, Error> { 149 + let Self { 150 + name, 151 + owner, 152 + repos: repo_path, 153 + hooks: _, 154 + git_config, 155 + bind: _, 156 + db: _, 157 + plc_directory: _, 158 + jetstream: _, 159 + auth_methods: _, 160 + require_signed_push: _, 161 + repo_cache_size, 162 + repo_cache_idle, 163 + repo_cache_live, 164 + archive_bz2_command: _, 165 + archive_xz_command: _, 166 + } = self.clone(); 167 + 168 + // @TODO Validate? 169 + 170 + let instance = format!("did:web:{name}").parse()?; 171 + 172 + Ok(KnotConfiguration { 173 + owner, 174 + instance, 175 + repo_path, 176 + git_config, 177 + readmes: DEFAULT_READMES 178 + .iter() 179 + .map(|v| BString::new(v.to_vec())) 180 + .collect(), 181 + repo_cache: RepoCacheConfig { 182 + size: repo_cache_size, 183 + idle: Duration::from_secs(repo_cache_idle), 184 + live: Duration::from_secs(repo_cache_live), 185 + }, 186 + }) 187 + } 188 + 189 + pub fn init_resolver(&self, http: HttpClient) -> gordian_identity::Resolver { 190 + let plc_url = Url::parse(&self.plc_directory).expect("PLC directory should be a valid URL"); 191 + assert!(["http", "https"].contains(&plc_url.scheme())); 192 + 193 + gordian_identity::Resolver::builder() 194 + .plc_directory(self.plc_directory.clone()) 195 + .build_with(http) 196 + } 197 + } 198 + 199 + #[derive(Debug, thiserror::Error)] 200 + pub enum Error { 201 + #[error("unable to build 'did:web:{{name}}' from knot fqdn: {0}")] 202 + Name(#[from] gordian_types::did::Error), 203 + } 204 + 205 + #[derive(Clone, Debug, ValueEnum)] 206 + pub enum AuthenticationMethods { 207 + ServiceAuth, 208 + PublicKey, 209 + } 210 + 211 + fn default_repository_base() -> PathBuf { 212 + env::current_dir().expect("current working directory should be readable") 213 + } 214 + 215 + fn default_jetstream_instances() -> String { 216 + gordian_jetstream::PUBLIC_JETSTREAM_INSTANCES.join(",") 217 + } 218 + 219 + /// Forward a git hook to the internal API. 220 + /// 221 + /// This command is expected to be invoked by git during operations via 222 + /// the global hook shims. 223 + #[derive(Clone, Args)] 224 + pub struct HookArguments { 225 + /// Internal API endpoints. 226 + #[arg(long, value_delimiter = ',', env = gordian_knot::private::ENV_PRIVATE_ENDPOINTS)] 227 + pub api: Vec<Url>, 228 + 229 + /// DID of the repository owner. 230 + #[arg(long, env = gordian_knot::private::ENV_REPO_DID)] 231 + pub repo_did: OwnedDid, 232 + 233 + /// Record key of the repository. 234 + #[arg(long, env = gordian_knot::private::ENV_REPO_RKEY)] 235 + pub repo_rkey: String, 236 + 237 + /// Name of the hook to forward. 238 + pub hook: HookName, 239 + } 240 + 241 + impl fmt::Debug for HookArguments { 242 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 243 + f.debug_struct("HookArguments") 244 + // Suppress `url::Url`'s god-awful debug output. 245 + .field("api", &self.api.iter().map(Url::as_str).collect::<Vec<_>>()) 246 + .field("repo_did", &self.repo_did) 247 + .field("repo_rkey", &self.repo_rkey) 248 + .field("hook", &self.hook) 249 + .finish() 250 + } 251 + } 252 + 253 + #[derive(Clone, Copy, Debug, ValueEnum)] 254 + #[clap(rename_all = "kebab-case")] 255 + pub enum HookName { 256 + PreReceive, 257 + PostReceive, 258 + PostUpdate, 259 + } 260 + 261 + impl fmt::Display for HookName { 262 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 263 + f.write_str(match self { 264 + Self::PreReceive => "pre-receive", 265 + Self::PostReceive => "post-receive", 266 + Self::PostUpdate => "post-update", 267 + }) 268 + } 269 + } 270 + 271 + impl AsRef<std::path::Path> for HookName { 272 + fn as_ref(&self) -> &std::path::Path { 273 + std::path::Path::new(match self { 274 + Self::PreReceive => "pre-receive", 275 + Self::PostReceive => "post-receive", 276 + Self::PostUpdate => "post-update", 277 + }) 278 + } 279 + } 280 + 281 + impl HookName { 282 + pub fn iter_variants() -> impl Iterator<Item = HookName> { 283 + [Self::PreReceive, Self::PostReceive, Self::PostUpdate].into_iter() 284 + } 285 + }
+145
crates/gordian-knot/src/hooks.rs
··· 1 + use std::{ 2 + collections::HashMap, 3 + env, 4 + fs::{self, Permissions}, 5 + io::{self, Write}, 6 + os::unix::fs::PermissionsExt, 7 + path::Path, 8 + }; 9 + 10 + use axum::http::{HeaderMap, HeaderName, HeaderValue, header::InvalidHeaderName}; 11 + use bytes::Bytes; 12 + use gordian_knot::private; 13 + 14 + use crate::cli::{HookArguments, HookName}; 15 + 16 + /// Setup the global hooks directory at `path`. 17 + pub fn setup_global_hooks<P: AsRef<Path>>(path: P) -> io::Result<()> { 18 + let executable = env::current_exe() 19 + .map(|path| path.to_str().map(ToOwned::to_owned)) 20 + .expect("Current executable must be defined") 21 + .expect("Current executable must be valid utf8"); 22 + 23 + let _ = fs::create_dir_all(&path); 24 + for hook_name in HookName::iter_variants() { 25 + let hook_path = path.as_ref().join(hook_name); 26 + let script = format!( 27 + "#!/usr/bin/sh\n# This file is generated by gordian-knot. Do not modify.\n{executable} hook {hook_name}\n" 28 + ); 29 + std::fs::write(&hook_path, script)?; 30 + 31 + let permissions = Permissions::from_mode(0o755); 32 + std::fs::set_permissions(&hook_path, permissions)?; 33 + tracing::info!(?executable, ?hook_path, "git hook installed"); 34 + } 35 + Ok(()) 36 + } 37 + 38 + /// [`core::fmt::Debug`] an [`url::Url`] without causing eye-cancer. 39 + #[repr(transparent)] 40 + struct DebugUrl(url::Url); 41 + 42 + impl core::fmt::Debug for DebugUrl { 43 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 44 + core::fmt::Display::fmt(&self.0, f) 45 + } 46 + } 47 + 48 + /// [`core::fmt::Debug`] a slice [`url::Url`] without causing eye-cancer. 49 + pub struct DebugUrls<'a>(pub &'a [url::Url]); 50 + 51 + impl<'a> core::fmt::Debug for DebugUrls<'a> { 52 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 53 + let urls = unsafe { 54 + // SAFETY: Close your eyes an pray! 55 + &*(self.0 as *const [url::Url] as *const [DebugUrl]) 56 + }; 57 + core::fmt::Debug::fmt(&urls, f) 58 + } 59 + } 60 + 61 + #[tracing::instrument(fields(api = ?DebugUrls(&api)))] 62 + pub async fn run_hook( 63 + HookArguments { 64 + api, 65 + repo_did, 66 + repo_rkey, 67 + hook, 68 + }: HookArguments, 69 + ) -> anyhow::Result<()> { 70 + if api.is_empty() { 71 + tracing::warn!("internal API not specified, skipping hook"); 72 + return Ok(()); 73 + }; 74 + 75 + let mut environment_vars: HashMap<_, _> = env::vars() 76 + .filter(|(key, _)| !key.trim().is_empty()) 77 + .collect(); 78 + 79 + let request_id = take_var(&mut environment_vars, "X_REQUEST_ID").ok(); 80 + 81 + // Build a header map with the remaining environment variables. 82 + let mut headers = HeaderMap::with_capacity(environment_vars.len()); 83 + if let Some(request_id) = request_id { 84 + headers.insert("X-Request-ID", HeaderValue::from_str(&request_id)?); 85 + } 86 + 87 + for (key, value) in environment_vars { 88 + match (variable_to_header_name(&key), HeaderValue::try_from(&value)) { 89 + (Ok(key), Ok(value)) => _ = headers.insert(key, value), 90 + (Err(error), _) => tracing::warn!(?error, ?key, ?value, "ignoring header"), 91 + (_, Err(error)) => tracing::warn!(?error, ?key, ?value, "ignoring header"), 92 + } 93 + } 94 + 95 + let stdin = Bytes::from(io::read_to_string(io::stdin())?); 96 + 97 + let client = reqwest::Client::new(); 98 + let url_path = format!("/hook/{repo_did}/{repo_rkey}/{hook}"); 99 + for mut hook_url in api { 100 + hook_url.set_path(&url_path); 101 + let response = client 102 + .post(hook_url) 103 + .headers(headers.clone()) 104 + .body(stdin.clone()) 105 + .send() 106 + .await; 107 + 108 + match response { 109 + Ok(response) if response.status().is_success() => { 110 + let body = response.bytes().await?; 111 + io::stdout().write_all(&body)?; 112 + return Ok(()); 113 + } 114 + Ok(response) => { 115 + let status = response.status(); 116 + let body = response.bytes().await?; 117 + io::stdout().write_all(&body)?; 118 + return Err(anyhow::anyhow!("Knot returned error status {status}")); 119 + } 120 + Err(error) => { 121 + tracing::error!(?error, "failed to post hook to internal API"); 122 + continue; 123 + } 124 + } 125 + } 126 + 127 + Err(anyhow::anyhow!("Failed to find a valid internal endpoint")) 128 + } 129 + 130 + fn take_var(vars: &mut HashMap<String, String>, name: &str) -> anyhow::Result<String> { 131 + vars.remove(name).ok_or(anyhow::anyhow!( 132 + "Expected environment variable {name:?} to be set", 133 + )) 134 + } 135 + 136 + fn variable_to_header_name(name: &str) -> Result<HeaderName, InvalidHeaderName> { 137 + format!( 138 + "{}-{}", 139 + private::ENV_HEADER_PREFIX, 140 + name.trim_start_matches("GORDIAN_") 141 + ) 142 + .replace('_', "-") 143 + .to_lowercase() 144 + .try_into() 145 + }
+687
crates/gordian-knot/src/lib.rs
··· 1 + use std::io; 2 + 3 + use axum::Router; 4 + use gordian_types::Nsid; 5 + use tokio::{net::TcpListener, task::JoinSet}; 6 + use tokio_util::sync::CancellationToken; 7 + 8 + pub mod extractors; 9 + pub mod model; 10 + pub mod private; 11 + pub mod public; 12 + pub mod services; 13 + pub mod sync; 14 + pub mod types; 15 + mod util; 16 + 17 + #[cfg(test)] 18 + pub(crate) mod mock; 19 + 20 + pub mod nsid { 21 + use gordian_types::Nsid; 22 + 23 + macro_rules! nsid { 24 + ($nsid:literal) => { 25 + unsafe { Nsid::from_static_unchecked($nsid) } 26 + }; 27 + } 28 + 29 + pub const SH_TANGLED_KNOT_MEMBER: &Nsid = nsid!("sh.tangled.knot.member"); 30 + pub const SH_TANGLED_PUBLICKEY: &Nsid = nsid!("sh.tangled.publicKey"); 31 + pub const SH_TANGLED_REPO: &Nsid = nsid!("sh.tangled.repo"); 32 + pub const SH_TANGLED_REPO_COLLABORATOR: &Nsid = nsid!("sh.tangled.repo.collaborator"); 33 + pub const SH_TANGLED_REPO_CREATE: &Nsid = nsid!("sh.tangled.repo.create"); 34 + pub const SH_TANGLED_REPO_DELETE: &Nsid = nsid!("sh.tangled.repo.delete"); 35 + pub const SH_TANGLED_REPO_GITRECEIVEPACK: &Nsid = nsid!("sh.tangled.repo.gitReceivePack"); 36 + pub const SH_TANGLED_REPO_SETDEFAULTBRANCH: &Nsid = nsid!("sh.tangled.repo.setDefaultBranch"); 37 + } 38 + 39 + pub use gordian_lexicon as lexicon; 40 + 41 + /// NSIDs of interest to a knot server. 42 + pub const NSIDS: &[&Nsid] = { 43 + &[ 44 + nsid::SH_TANGLED_KNOT_MEMBER, 45 + nsid::SH_TANGLED_PUBLICKEY, 46 + nsid::SH_TANGLED_REPO, 47 + nsid::SH_TANGLED_REPO_COLLABORATOR, 48 + ] 49 + }; 50 + 51 + pub async fn serve_all( 52 + router: Router, 53 + listeners: impl IntoIterator<Item = TcpListener>, 54 + shutdown: CancellationToken, 55 + ) -> io::Result<()> { 56 + let mut service = JoinSet::new(); 57 + for listener in listeners { 58 + let router = router.clone(); 59 + let addr = listener.local_addr()?; 60 + tracing::info!(?addr, "listening on socket"); 61 + 62 + let shutdown = shutdown.child_token(); 63 + service.spawn(async move { 64 + axum::serve(listener, router) 65 + .with_graceful_shutdown(async move { shutdown.cancelled().await }) 66 + .await 67 + }); 68 + } 69 + 70 + for task in service.join_all().await { 71 + task?; 72 + } 73 + 74 + Ok(()) 75 + } 76 + 77 + #[cfg(test)] 78 + mod tests { 79 + use gordian_auth::jwt::Claims; 80 + use gordian_lexicon::sh_tangled; 81 + use gordian_types::{Did, Tid}; 82 + 83 + use axum::{ 84 + body::Body, 85 + http::{Request, StatusCode}, 86 + }; 87 + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 88 + use tower::ServiceExt; 89 + 90 + use crate::model::Knot; 91 + 92 + const TEST_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7"; 93 + const TEST_INSTANCE: &str = "lib-knot-test"; 94 + 95 + fn get(uri: &str) -> Request<Body> { 96 + Request::builder().uri(uri).body(Body::empty()).unwrap() 97 + } 98 + 99 + #[tokio::test] 100 + async fn can_query_knot_owner() { 101 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 102 + let response = super::public::router() 103 + .with_state(knot) 104 + .oneshot(get("/xrpc/sh.tangled.owner")) 105 + .await 106 + .unwrap(); 107 + 108 + assert_eq!(response.status(), StatusCode::OK); 109 + let body = axum::body::to_bytes(response.into_body(), 1000) 110 + .await 111 + .unwrap(); 112 + 113 + assert_eq!( 114 + body.as_ref(), 115 + format!("{{\"owner\":\"{TEST_DID}\"}}").as_bytes() 116 + ); 117 + 118 + let resp: sh_tangled::owner::Output = serde_json::from_slice(&body).unwrap(); 119 + assert_eq!(resp.owner.as_str(), TEST_DID); 120 + } 121 + 122 + #[tokio::test] 123 + async fn xrpc_sh_tangled_repo_missing_repo() { 124 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 125 + for particle in ["tree", "log", "tags", "branches"] { 126 + let response = super::public::router() 127 + .with_state(knot.clone()) 128 + .oneshot(get(&format!("/xrpc/sh.tangled.repo.{particle}"))) 129 + .await 130 + .unwrap(); 131 + 132 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 133 + } 134 + } 135 + 136 + #[tokio::test] 137 + async fn xrpc_sh_tangled_repo_bad_repo_format() { 138 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 139 + for particle in ["tree", "log", "tags", "branches"] { 140 + // Missing repo name 141 + let response = super::public::router() 142 + .with_state(knot.clone()) 143 + .oneshot(get(&format!( 144 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com" 145 + ))) 146 + .await 147 + .unwrap(); 148 + 149 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 150 + 151 + // Bad repo names '..' 152 + 153 + for repo_name in ["", "..", "../../secret-data", ".hidden", "/etc/passwd"] { 154 + let response = super::public::router() 155 + .with_state(knot.clone()) 156 + .oneshot(get(&format!( 157 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/{repo_name}" 158 + ))) 159 + .await 160 + .unwrap(); 161 + 162 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 163 + } 164 + } 165 + } 166 + 167 + #[tokio::test] 168 + async fn xrpc_sh_tangled_repo_not_found() { 169 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 170 + for particle in ["tree", "log", "tags", "branches"] { 171 + let response = super::public::router() 172 + .with_state(knot.clone()) 173 + .oneshot(get(&format!( 174 + "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/non-existent-repo" 175 + ))) 176 + .await 177 + .unwrap(); 178 + 179 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 180 + } 181 + } 182 + 183 + mod sh_tangled_repo_create { 184 + use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE}; 185 + 186 + use super::super::public; 187 + use super::*; 188 + use axum::http::{HeaderValue, Method, Response, header}; 189 + 190 + fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims 191 + where 192 + F: FnOnce(&mut Claims), 193 + { 194 + let jti: [u8; 16] = rand::random(); 195 + let jti = data_encoding::BASE32_NOPAD_VISUAL 196 + .encode(&jti) 197 + .to_lowercase(); 198 + 199 + let mut claims = Claims { 200 + iss: iss.into(), 201 + aud: aud.into(), 202 + iat: OffsetDateTime::now_utc().unix_timestamp(), 203 + exp: OffsetDateTime::now_utc().unix_timestamp() + 10, 204 + lxm: None, 205 + jti: jti.into(), 206 + }; 207 + 208 + modify_claims(&mut claims); 209 + claims 210 + } 211 + 212 + async fn service_auth_with<F>( 213 + pds: &mock_pds::Pds, 214 + iss: &Did, 215 + aud: &Did, 216 + modify_claims: F, 217 + ) -> HeaderValue 218 + where 219 + F: FnOnce(&mut Claims), 220 + { 221 + let claims = make_claims(iss, aud, modify_claims); 222 + let authorization = pds.service_auth(&claims).await; 223 + HeaderValue::from_str(&authorization).unwrap() 224 + } 225 + 226 + #[tokio::test] 227 + async fn reject_wrong_method() { 228 + let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 229 + let response = public::router() 230 + .with_state(knot.clone()) 231 + .oneshot(get("/xrpc/sh.tangled.repo.create")) 232 + .await 233 + .unwrap(); 234 + 235 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 236 + } 237 + 238 + async fn create_repo_with<F>( 239 + knot: &Knot, 240 + pds: mock_pds::Pds, 241 + did: &Did, 242 + rkey: &str, 243 + repo_name: &str, 244 + source: Option<&str>, 245 + modify_claims: F, 246 + ) -> Response<Body> 247 + where 248 + F: Fn(&mut Claims) + Copy, 249 + { 250 + // Create fake PDS record for our new repository. 251 + pds.insert_record( 252 + did, 253 + "sh.tangled.repo", 254 + rkey, 255 + &serde_json::json!({ 256 + "name": repo_name, 257 + "knot": knot.instance_ident(), 258 + "source": source, 259 + "createdAt": OffsetDateTime::now_utc().format(&Rfc3339).unwrap() 260 + }), 261 + ) 262 + .await; 263 + 264 + // Generate the body of the 'sh.tangled.repo.create' request. 265 + let create = sh_tangled::repo::create::Input { 266 + rkey: rkey.to_string(), 267 + default_branch: Some("main".into()), 268 + source: None, 269 + }; 270 + 271 + let auth = service_auth_with(&pds, &did, &knot.instance, |claims| { 272 + claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 273 + modify_claims(claims); 274 + }) 275 + .await; 276 + 277 + let response = public::router() 278 + .with_state(knot.clone()) 279 + .oneshot( 280 + Request::post("/xrpc/sh.tangled.repo.create") 281 + .header(header::AUTHORIZATION, auth) 282 + .header(header::CONTENT_TYPE, "application/json") 283 + .body(Body::new(serde_json::to_string(&create).unwrap())) 284 + .expect("sh.tangled.repo.create request"), 285 + ) 286 + .await 287 + .expect("xrpc response"); 288 + 289 + response 290 + } 291 + 292 + async fn create_repo( 293 + knot: &Knot, 294 + pds: mock_pds::Pds, 295 + did: &Did, 296 + rkey: &str, 297 + repo_name: &str, 298 + source: Option<&str>, 299 + ) -> Response<Body> { 300 + create_repo_with(knot, pds, did, rkey, repo_name, source, |_| {}).await 301 + } 302 + 303 + async fn repo_exists_in_db(knot: &Knot, did: &Did, rkey: &str) -> bool { 304 + knot.resolve_repo_key(&crate::types::repository_path::RepositoryPath { 305 + owner: did.into_boxed().into(), 306 + name: rkey.into(), 307 + }) 308 + .await 309 + .is_ok() 310 + } 311 + 312 + #[tokio::test] 313 + async fn can_create_repo() { 314 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 315 + 316 + let did = Did::from_static(TEST_DID); 317 + pds.insert_identity(did, "tjh.dev").await; 318 + knot.add_member( 319 + "", 320 + "", 321 + "", 322 + &sh_tangled::knot::Member::new( 323 + &did, 324 + knot.instance_ident(), 325 + OffsetDateTime::now_utc(), 326 + ), 327 + ) 328 + .await 329 + .unwrap(); 330 + 331 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 332 + assert_eq!( 333 + create_repo(&knot, pds, did, &rkey, "test-repo", None) 334 + .await 335 + .status(), 336 + StatusCode::OK 337 + ); 338 + 339 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 340 + } 341 + 342 + #[tokio::test] 343 + async fn can_create_fork_from_at() { 344 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 345 + 346 + let did = Did::from_static(TEST_DID); 347 + pds.insert_identity(did, "tjh.dev").await; 348 + knot.add_member( 349 + "", 350 + "", 351 + "", 352 + &sh_tangled::knot::Member::new( 353 + &did, 354 + knot.instance_ident(), 355 + OffsetDateTime::now_utc(), 356 + ), 357 + ) 358 + .await 359 + .unwrap(); 360 + 361 + // Create a record for the repository to fork from. 362 + // <https://pdsls.dev/at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22#record> 363 + let aturi = pds 364 + .insert_record( 365 + did, 366 + "sh.tangled.repo", 367 + "3m24udbjajf22", 368 + &serde_json::json!({ 369 + "name": "gordian", 370 + "knot": "gordian.tjh.dev", 371 + "createdAt": "2025-10-01T10:45:52Z" 372 + }), 373 + ) 374 + .await; 375 + 376 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 377 + assert_eq!( 378 + create_repo(&knot, pds, did, &rkey, "test-repo", Some(&aturi)) 379 + .await 380 + .status(), 381 + StatusCode::OK 382 + ); 383 + 384 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 385 + } 386 + 387 + #[tokio::test] 388 + async fn can_create_fork_from_http() { 389 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 390 + 391 + let did = Did::from_static(TEST_DID); 392 + pds.insert_identity(did, "tjh.dev").await; 393 + knot.add_member( 394 + "", 395 + "", 396 + "", 397 + &sh_tangled::knot::Member::new( 398 + &did, 399 + knot.instance_ident(), 400 + OffsetDateTime::now_utc(), 401 + ), 402 + ) 403 + .await 404 + .unwrap(); 405 + 406 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 407 + let source = 408 + Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpzmvpbwovss7/3m24udbjajf22"); 409 + assert_eq!( 410 + create_repo(&knot, pds, did, &rkey, "test-repo", source) 411 + .await 412 + .status(), 413 + StatusCode::OK 414 + ); 415 + 416 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 417 + } 418 + 419 + #[tokio::test] 420 + async fn can_create_fork_from_http_fail() { 421 + let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 422 + 423 + let did = Did::from_static(TEST_DID); 424 + pds.insert_identity(did, "tjh.dev").await; 425 + knot.add_member( 426 + "", 427 + "", 428 + "", 429 + &sh_tangled::knot::Member::new( 430 + &did, 431 + knot.instance_ident(), 432 + OffsetDateTime::now_utc(), 433 + ), 434 + ) 435 + .await 436 + .unwrap(); 437 + 438 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 439 + let source = 440 + Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpmvpbwovss7/3m24udbjajf22"); 441 + 442 + assert_ne!( 443 + create_repo(&knot, pds, did, &rkey, "test-repo", source) 444 + .await 445 + .status(), 446 + StatusCode::OK 447 + ); 448 + 449 + // Verifiy the repository wasn't created on disk. 450 + assert!( 451 + std::fs::exists(base.path().join(did.as_str()).join(&rkey)).is_ok_and(|val| !val), 452 + ); 453 + 454 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 455 + } 456 + 457 + #[tokio::test] 458 + async fn rejects_if_owner_is_not_a_member() { 459 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 460 + 461 + let did = Did::from_static(TEST_DID); 462 + pds.insert_identity(did, "tjh.dev").await; 463 + 464 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 465 + assert_ne!( 466 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |_| {}) 467 + .await 468 + .status(), 469 + StatusCode::OK, 470 + ); 471 + 472 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 473 + } 474 + 475 + #[tokio::test] 476 + async fn rejects_auth_issued_in_future() { 477 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 478 + 479 + let did = Did::from_static(TEST_DID); 480 + pds.insert_identity(did, "tjh.dev").await; 481 + knot.add_member( 482 + "", 483 + "", 484 + "", 485 + &sh_tangled::knot::Member::new( 486 + &did, 487 + knot.instance_ident(), 488 + OffsetDateTime::now_utc(), 489 + ), 490 + ) 491 + .await 492 + .unwrap(); 493 + 494 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 495 + assert_eq!( 496 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 497 + // 498 + claims.iat = OffsetDateTime::now_utc().unix_timestamp() + 60; 499 + }) 500 + .await 501 + .status(), 502 + StatusCode::FORBIDDEN, 503 + "iat > now => should be 403 Forbidden" 504 + ); 505 + 506 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 507 + } 508 + 509 + #[tokio::test] 510 + async fn rejects_auth_expired() { 511 + let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 512 + 513 + let did = Did::from_static(TEST_DID); 514 + pds.insert_identity(did, "tjh.dev").await; 515 + knot.add_member( 516 + "", 517 + "", 518 + "", 519 + &sh_tangled::knot::Member::new( 520 + &did, 521 + knot.instance_ident(), 522 + OffsetDateTime::now_utc(), 523 + ), 524 + ) 525 + .await 526 + .unwrap(); 527 + 528 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 529 + assert_eq!( 530 + create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 531 + // 532 + claims.exp = OffsetDateTime::now_utc().unix_timestamp() - 1; 533 + }) 534 + .await 535 + .status(), 536 + StatusCode::FORBIDDEN, 537 + "exp < now => should be 403 Forbidden" 538 + ); 539 + } 540 + 541 + #[tokio::test] 542 + async fn can_delete_repo() { 543 + let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 544 + 545 + let did = Did::from_static(TEST_DID); 546 + pds.insert_identity(did, "tjh.dev").await; 547 + knot.add_member( 548 + "", 549 + "", 550 + "", 551 + &sh_tangled::knot::Member::new( 552 + &did, 553 + knot.instance_ident(), 554 + OffsetDateTime::now_utc(), 555 + ), 556 + ) 557 + .await 558 + .unwrap(); 559 + 560 + let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 561 + let name = "another-test-repo"; 562 + assert_eq!( 563 + create_repo(&knot, pds.clone(), did, &rkey, name, None) 564 + .await 565 + .status(), 566 + StatusCode::OK 567 + ); 568 + 569 + gix::open(base.path().join(did.as_str()).join(&rkey)) 570 + .expect("new repository should exist"); 571 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 572 + 573 + let delete = sh_tangled::repo::delete::Input { 574 + did: did.to_owned(), 575 + rkey: rkey.clone(), 576 + name: "another-test-repo".to_string(), 577 + }; 578 + 579 + // First check we cannot delete without auth. 580 + assert_eq!( 581 + public::router() 582 + .with_state(knot.clone()) 583 + .oneshot( 584 + Request::builder() 585 + .method(Method::POST) 586 + .uri("/xrpc/sh.tangled.repo.delete") 587 + .header(header::CONTENT_TYPE, "application/json") 588 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 589 + .expect("sh.tangled.repo.delete request"), 590 + ) 591 + .await 592 + .expect("xrpc response") 593 + .status(), 594 + StatusCode::UNAUTHORIZED 595 + ); 596 + 597 + // Check repository has not been deleted. 598 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 599 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 600 + 601 + // Or with the wrong lxm. 602 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 603 + claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 604 + }) 605 + .await; 606 + 607 + assert_eq!( 608 + public::router() 609 + .with_state(knot.clone()) 610 + .oneshot( 611 + Request::builder() 612 + .method(Method::POST) 613 + .uri("/xrpc/sh.tangled.repo.delete") 614 + .header(header::CONTENT_TYPE, "application/json") 615 + .header(header::AUTHORIZATION, auth) 616 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 617 + .expect("sh.tangled.repo.delete request"), 618 + ) 619 + .await 620 + .expect("xrpc response") 621 + .status(), 622 + StatusCode::FORBIDDEN 623 + ); 624 + 625 + // Check repository has not been deleted. 626 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 627 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 628 + 629 + // Valid auth, empty request body. 630 + // Or with the wrong auth. 631 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 632 + claims.lxm = Some(SH_TANGLED_REPO_DELETE.into_boxed()); 633 + }) 634 + .await; 635 + assert_eq!( 636 + public::router() 637 + .with_state(knot.clone()) 638 + .oneshot( 639 + Request::builder() 640 + .method(Method::POST) 641 + .uri("/xrpc/sh.tangled.repo.delete") 642 + .header(header::CONTENT_TYPE, "application/json") 643 + .header(header::AUTHORIZATION, auth) 644 + .body(Body::empty()) 645 + .expect("sh.tangled.repo.delete request"), 646 + ) 647 + .await 648 + .expect("xrpc response") 649 + .status(), 650 + StatusCode::BAD_REQUEST 651 + ); 652 + 653 + // Check repository has not been deleted. 654 + gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 655 + assert!(repo_exists_in_db(&knot, &did, &rkey).await); 656 + 657 + // Or with the wrong auth. 658 + let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 659 + claims.lxm = Some("sh.tangled.repo.delete".try_into().unwrap()); 660 + }) 661 + .await; 662 + 663 + assert_eq!( 664 + public::router() 665 + .with_state(knot.clone()) 666 + .oneshot( 667 + Request::builder() 668 + .method(Method::POST) 669 + .uri("/xrpc/sh.tangled.repo.delete") 670 + .header(header::CONTENT_TYPE, "application/json") 671 + .header(header::AUTHORIZATION, auth) 672 + .body(Body::new(serde_json::to_string(&delete).unwrap())) 673 + .expect("sh.tangled.repo.delete request"), 674 + ) 675 + .await 676 + .expect("xrpc response") 677 + .status(), 678 + StatusCode::OK 679 + ); 680 + 681 + // Check repository has been deleted. 682 + gix::open(base.path().join(did.as_str()).join(&rkey)) 683 + .expect_err("deleted repository should not exist"); 684 + assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 685 + } 686 + } 687 + }
+290
crates/gordian-knot/src/main.rs
··· 1 + mod cli; 2 + mod hooks; 3 + 4 + use anyhow::Context as _; 5 + use axum::http::{Request, Response}; 6 + use futures_util::FutureExt as _; 7 + use gordian_knot::{ 8 + model::{Knot, KnotState, config::KnotConfiguration}, 9 + services::database::DataStore, 10 + }; 11 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 12 + use std::{env, ffi::OsStr, net::ToSocketAddrs as _, time::Duration}; 13 + use tokio::{net::TcpListener, signal::unix::SignalKind, task::JoinSet}; 14 + use tokio::{runtime::Builder, signal}; 15 + use tokio_util::sync::CancellationToken; 16 + use tower::ServiceBuilder; 17 + use tower_http::{ 18 + ServiceBuilderExt as _, 19 + decompression::RequestDecompressionLayer, 20 + request_id::{MakeRequestUuid, RequestId}, 21 + trace::{MakeSpan, OnResponse, TraceLayer}, 22 + }; 23 + use tracing::{Span, field::Empty, level_filters::LevelFilter}; 24 + use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _}; 25 + 26 + #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] 27 + use tikv_jemallocator::Jemalloc; 28 + 29 + #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] 30 + #[global_allocator] 31 + static GLOBAL: Jemalloc = Jemalloc; 32 + 33 + const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 34 + 35 + fn main() -> anyhow::Result<()> { 36 + tracing_subscriber::registry() 37 + .with( 38 + EnvFilter::builder() 39 + .with_default_directive(LevelFilter::INFO.into()) 40 + .from_env_lossy(), 41 + ) 42 + .with( 43 + tracing_subscriber::fmt::layer() 44 + .with_writer(std::io::stderr) 45 + .without_time(), 46 + ) 47 + .init(); 48 + 49 + let runtime = Builder::new_current_thread() 50 + .enable_all() 51 + .build() 52 + .expect("Failed to build runtime"); 53 + 54 + match cli::parse() { 55 + cli::KnotCommand::Generate(_) => unreachable!("Handled by cli module"), 56 + cli::KnotCommand::Serve(arguments) => runtime.block_on(knot_main(arguments)), 57 + cli::KnotCommand::Hook(arguments) => runtime.block_on(hooks::run_hook(arguments)), 58 + } 59 + } 60 + 61 + pub async fn knot_main(arguments: cli::ServeArguments) -> anyhow::Result<()> { 62 + unsafe { env::set_var("GIT_CONFIG_GLOBAL", &arguments.git_config) }; 63 + 64 + let tempdir = tempfile::TempDir::with_prefix("gordian-knot-")?; 65 + let hooks_path = if let Some(path) = &arguments.hooks { 66 + // @TODO Verify hooks exist in the specified path. 67 + tracing::warn!(?path, "assuming existence of hooks at path"); 68 + path.to_path_buf() 69 + } else { 70 + let path = tempdir.path().join("hooks"); 71 + hooks::setup_global_hooks(&path)?; 72 + path 73 + }; 74 + 75 + assert!(git_config_global("core.hooksPath", &hooks_path)?); 76 + assert!(git_config_global("receive.advertisePushOptions", "true")?); 77 + if let Some(command) = &arguments.archive_bz2_command { 78 + assert!(git_config_global("tar.tar.bz2.command", command)?); 79 + } 80 + if let Some(command) = &arguments.archive_xz_command { 81 + assert!(git_config_global("tar.tar.xz.command", command)?); 82 + } 83 + 84 + let database = { 85 + let pool = { 86 + let connect_options = SqliteConnectOptions::new() 87 + .filename(&arguments.db) 88 + .create_if_missing(true) 89 + .foreign_keys(true) 90 + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); 91 + 92 + SqlitePoolOptions::new() 93 + .connect_with(connect_options) 94 + .await? 95 + }; 96 + 97 + sqlx::migrate!().run(&pool).await?; 98 + DataStore::new(pool) 99 + }; 100 + 101 + let public_http = reqwest::ClientBuilder::new() 102 + .timeout(Duration::from_secs(2)) 103 + .user_agent(USER_AGENT) 104 + .http2_keep_alive_while_idle(true) 105 + .https_only(true) 106 + .build() 107 + .context("Failed to build public HTTP client")?; 108 + 109 + let resolver = arguments.init_resolver(public_http.clone()); 110 + 111 + // Bind listeners for the public API. 112 + let mut public_listeners = Vec::with_capacity(arguments.bind.len()); 113 + for addr in &arguments.bind { 114 + for socket in addr.to_socket_addrs()? { 115 + let listener = TcpListener::bind(socket).await?; 116 + public_listeners.push(listener); 117 + } 118 + } 119 + 120 + // Bind listeners for the private API. 121 + let mut private_listeners = Vec::with_capacity(2); 122 + for socket in "localhost:0".to_socket_addrs()? { 123 + let listener = TcpListener::bind(socket).await?; 124 + private_listeners.push(listener); 125 + } 126 + 127 + // The knot needs to know the sockets we've bound the private API. 128 + let private_addrs = private_listeners 129 + .iter() 130 + .map(tokio::net::TcpListener::local_addr) 131 + .collect::<Result<Vec<_>, std::io::Error>>()?; 132 + 133 + tracing::info!(?private_addrs, "bound internal API"); 134 + 135 + let config: KnotConfiguration = arguments.to_knot_config()?; 136 + let knot_state = KnotState::new(config, resolver, public_http, database, &private_addrs)?; 137 + let knot = Knot::from(knot_state); 138 + 139 + // Ensure the knot owner's records are seeded. 140 + knot.seed_owner() 141 + .await 142 + .context("seeding knot owner's records")?; 143 + 144 + let mut tasks = JoinSet::new(); 145 + let shutdown = CancellationToken::new(); 146 + 147 + // Spawn the internal API. 148 + tasks.spawn(gordian_knot::serve_all( 149 + gordian_knot::private::router() 150 + .layer( 151 + ServiceBuilder::new() 152 + .set_x_request_id(MakeRequestUuid) 153 + .layer( 154 + TraceLayer::new_for_http() 155 + .make_span_with(PrivateHttpSpan) 156 + .on_request(|_: &Request<_>, _: &Span| {}) 157 + .on_response(TraceResponse), 158 + ) 159 + .propagate_x_request_id(), 160 + ) 161 + .with_state(knot.clone()), 162 + private_listeners, 163 + shutdown.child_token(), 164 + )); 165 + 166 + // Spawn the jetstream consumer. 167 + tasks.spawn( 168 + gordian_knot::services::jetstream::init_consumer( 169 + &knot, 170 + arguments.jetstream.as_slice(), 171 + shutdown.child_token(), 172 + ) 173 + .map(|_| Ok(())), 174 + ); 175 + 176 + // Build the public API. 177 + let router = gordian_knot::public::router() 178 + .layer(RequestDecompressionLayer::new()) 179 + .layer( 180 + ServiceBuilder::new() 181 + .set_x_request_id(MakeRequestUuid) 182 + .layer( 183 + TraceLayer::new_for_http() 184 + .make_span_with(PublicHttpSpan) 185 + .on_request(|_: &Request<_>, _: &Span| {}) 186 + .on_response(TraceResponse), 187 + ) 188 + .propagate_x_request_id(), 189 + ) 190 + .with_state(knot); 191 + 192 + tasks.spawn(gordian_knot::serve_all( 193 + router, 194 + public_listeners, 195 + shutdown.child_token(), 196 + )); 197 + 198 + tasks.spawn(wait_for_shutdown(shutdown)); 199 + 200 + for task in tasks.join_all().await { 201 + if let Err(error) = task { 202 + tracing::error!(?error, "knot task completed with error"); 203 + } 204 + } 205 + 206 + Ok(()) 207 + } 208 + 209 + async fn wait_for_shutdown(shutdown: CancellationToken) -> std::io::Result<()> { 210 + let mut sigterm = signal::unix::signal(SignalKind::terminate())?; 211 + 212 + tokio::select! { 213 + Ok(()) = signal::ctrl_c() => { 214 + eprintln!(); 215 + tracing::info!("ctrl+c received, shutting down ..."); 216 + }, 217 + Some(()) = sigterm.recv() => { 218 + tracing::info!("SIGTERM received, shutting down ..."); 219 + } 220 + } 221 + 222 + shutdown.cancel(); 223 + 224 + Ok(()) 225 + } 226 + 227 + fn git_config_global<K, V>(key: K, value: V) -> std::io::Result<bool> 228 + where 229 + K: AsRef<OsStr>, 230 + V: AsRef<OsStr>, 231 + { 232 + use std::process::Stdio; 233 + 234 + let success = std::process::Command::new("/usr/bin/git") 235 + .args(["config", "set", "--global"]) 236 + .arg(key) 237 + .arg(value) 238 + .stdout(Stdio::inherit()) 239 + .stderr(Stdio::inherit()) 240 + .spawn()? 241 + .wait()? 242 + .success(); 243 + 244 + Ok(success) 245 + } 246 + 247 + macro_rules! make_span { 248 + ($name:ident, $label:literal) => { 249 + #[derive(Clone)] 250 + struct $name; 251 + 252 + impl<B> MakeSpan<B> for $name { 253 + fn make_span(&mut self, request: &axum::http::Request<B>) -> tracing::Span { 254 + let method = request.method(); 255 + let path = request.uri().path(); 256 + 257 + let span = tracing::error_span!($label, id = Empty, method = Empty, path = Empty); 258 + if let Some(id) = request 259 + .extensions() 260 + .get::<RequestId>() 261 + .and_then(|request_id| request_id.header_value().to_str().ok()) 262 + { 263 + span.record("id", &id); 264 + } 265 + 266 + span.record("method", tracing::field::debug(&method)); 267 + span.record("path", tracing::field::debug(&path)); 268 + 269 + span 270 + } 271 + } 272 + }; 273 + } 274 + 275 + make_span!(PublicHttpSpan, "public"); 276 + make_span!(PrivateHttpSpan, "private"); 277 + 278 + #[derive(Clone)] 279 + pub struct TraceResponse; 280 + 281 + impl<B> OnResponse<B> for TraceResponse { 282 + fn on_response(self, response: &Response<B>, latency: Duration, _: &Span) { 283 + match response.status() { 284 + status if status.is_success() => tracing::trace!(?status, ?latency), 285 + status if status.is_client_error() => tracing::warn!(?status, ?latency), 286 + status if status.is_server_error() => tracing::error!(?status, ?latency), 287 + status => tracing::info!(?status, ?latency), 288 + } 289 + } 290 + }
+35
crates/gordian-knot/src/mock.rs
··· 1 + use crate::{ 2 + model::{Knot, config::KnotConfiguration}, 3 + services::database::DataStore, 4 + }; 5 + use gordian_identity::Resolver; 6 + use gordian_types::OwnedDid; 7 + 8 + pub async fn setup( 9 + owner_did: &str, 10 + instance_name: &str, 11 + ) -> (tempfile::TempDir, mock_pds::Pds, Knot) { 12 + let base = tempfile::tempdir().expect("temporary directory"); 13 + let pool = sqlx::SqlitePool::connect("sqlite://:memory:") 14 + .await 15 + .unwrap(); 16 + 17 + sqlx::migrate!().run(&pool).await.unwrap(); 18 + 19 + let (pds, listener) = mock_pds::init().await; 20 + let pds_api = mock_pds::router(pds.clone()); 21 + tokio::spawn(async move { 22 + axum::serve(listener, pds_api).await.unwrap(); 23 + }); 24 + 25 + let owner_did = OwnedDid::parse(owner_did).expect("owner DID must be valid"); 26 + let instance = OwnedDid::parse(format!("did:web:{instance_name}")) 27 + .expect("instance name should form a valid DID"); 28 + 29 + let database = DataStore::new(pool); 30 + let resolver = Resolver::new(pds.clone()); 31 + let config = KnotConfiguration::new(owner_did.clone(), instance, base.path()); 32 + let knot = Knot::new(config, resolver, reqwest::Client::new(), database, []).unwrap(); 33 + 34 + (base, pds, knot) 35 + }
+260
crates/gordian-knot/src/model.rs
··· 1 + pub mod config; 2 + pub mod convert; 3 + pub mod errors; 4 + pub mod knot_state; 5 + pub mod nicediff; 6 + pub mod repository; 7 + 8 + use core::ops; 9 + use std::{borrow::Cow, ffi::OsString, net::SocketAddr, sync::Arc}; 10 + 11 + use axum::{ 12 + extract::{FromRef, FromRequestParts, OptionalFromRequestParts}, 13 + http::request::Parts, 14 + }; 15 + use futures_util::future::BoxFuture; 16 + use git_service::{state::GitServiceState, util::SetOptionEnv as _}; 17 + use gordian_auth::jwt; 18 + use gordian_identity::{HttpClient, Resolver}; 19 + use gordian_lexicon::sh_tangled::knot::Member; 20 + use gordian_types::Tid; 21 + use time::OffsetDateTime; 22 + use tokio::process::Command; 23 + 24 + use crate::{ 25 + extractors::request_id::RequestId, 26 + model::{config::KnotConfiguration, repository::TangledRepository}, 27 + private, 28 + public::git::{Error, GitAuthorization}, 29 + services::{ 30 + authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 31 + database::DataStore, 32 + }, 33 + }; 34 + 35 + pub use knot_state::KnotState; 36 + 37 + #[derive(Debug, Clone)] 38 + #[repr(transparent)] 39 + pub struct Knot { 40 + inner: Arc<KnotState>, 41 + } 42 + 43 + impl From<Arc<KnotState>> for Knot { 44 + #[inline] 45 + fn from(inner: Arc<KnotState>) -> Self { 46 + Self { inner } 47 + } 48 + } 49 + 50 + impl FromRef<Knot> for Resolver { 51 + #[inline] 52 + fn from_ref(input: &Knot) -> Self { 53 + input.resolver().clone() 54 + } 55 + } 56 + 57 + impl ops::Deref for Knot { 58 + type Target = KnotState; 59 + #[inline] 60 + fn deref(&self) -> &Self::Target { 61 + &self.inner 62 + } 63 + } 64 + 65 + impl Knot { 66 + pub fn new<'a>( 67 + config: KnotConfiguration, 68 + resolver: Resolver, 69 + http: HttpClient, 70 + database: DataStore, 71 + private_binds: impl IntoIterator<Item = &'a SocketAddr>, 72 + ) -> std::io::Result<Self> { 73 + let inner = KnotState::new(config, resolver, http, database, private_binds)?; 74 + Ok(Self { inner }) 75 + } 76 + 77 + pub async fn add_member( 78 + &self, 79 + rkey: &str, 80 + rev: &str, 81 + cid: &str, 82 + member: &Member<'_>, 83 + ) -> anyhow::Result<()> { 84 + let new_member = self 85 + .database() 86 + .upsert_knot_member(rkey, rev, cid, member) 87 + .await?; 88 + 89 + if new_member { 90 + tracing::info!(member = %member.subject, "new knot member"); 91 + crate::services::seed::public_keys(self, &member.subject).await?; 92 + crate::services::seed::repositories(self, &member.subject).await?; 93 + } 94 + 95 + Ok(()) 96 + } 97 + 98 + pub async fn seed_owner(&self) -> anyhow::Result<()> { 99 + self.add_member( 100 + "", 101 + &Tid::MAX.to_string(), 102 + "", 103 + &Member { 104 + subject: Cow::Borrowed(self.owner()), 105 + domain: Cow::Borrowed(self.instance_ident()), 106 + created_at: OffsetDateTime::now_utc(), 107 + }, 108 + ) 109 + .await 110 + } 111 + } 112 + 113 + impl AuthorizationClaimsStore<jwt::Claims> for Knot { 114 + fn get_unexpired_claims<'a: 'b, 'b>( 115 + &'a self, 116 + jti: &'b str, 117 + now: i64, 118 + ) -> BoxFuture<'b, Result<Option<jwt::Claims>, AuthorizationClaimsStoreError>> { 119 + self.inner.get_unexpired_claims(jti, now) 120 + } 121 + 122 + fn store_claims( 123 + &self, 124 + claims: jwt::Claims, 125 + now: i64, 126 + ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>> { 127 + self.inner.store_claims(claims, now) 128 + } 129 + } 130 + 131 + impl GitServiceState for Knot { 132 + type Rejection = Error; 133 + 134 + async fn init_upload_archive(&self, parts: &mut Parts) -> Result<Command, Self::Rejection> { 135 + let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 136 + let repository = TangledRepository::from_git_request(parts, self).await?; 137 + let mut command = repository.git(); 138 + command 139 + .option_env("X_REQUEST_ID", request_id) 140 + .args(["upload-archive"]) 141 + .arg(repository.path()); 142 + 143 + Ok(command.into()) 144 + } 145 + 146 + async fn init_upload_pack_advertisement( 147 + &self, 148 + parts: &mut Parts, 149 + ) -> Result<tokio::process::Command, Self::Rejection> { 150 + let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 151 + let repository = TangledRepository::from_git_request(parts, self).await?; 152 + let mut command = repository.git(); 153 + command 154 + .option_env("X_REQUEST_ID", request_id) 155 + .args([ 156 + "upload-pack", 157 + "--http-backend-info-refs", 158 + "--stateless-rpc", 159 + "--strict", 160 + "--timeout=10", 161 + ]) 162 + .arg(repository.path()); 163 + 164 + Ok(command.into()) 165 + } 166 + 167 + async fn init_upload_pack( 168 + &self, 169 + parts: &mut Parts, 170 + ) -> Result<tokio::process::Command, Self::Rejection> { 171 + let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 172 + let repository = TangledRepository::from_git_request(parts, self).await?; 173 + let mut command = repository.git(); 174 + command 175 + .option_env("X_REQUEST_ID", request_id) 176 + .args(["upload-pack", "--strict", "--stateless-rpc"]) 177 + .arg(repository.path()); 178 + 179 + Ok(command.into()) 180 + } 181 + 182 + async fn init_receive_pack_advertisement( 183 + &self, 184 + parts: &mut Parts, 185 + ) -> Result<tokio::process::Command, Self::Rejection> { 186 + let GitAuthorization(auth) = GitAuthorization::from_request_parts(parts, self).await?; 187 + let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 188 + let repository = TangledRepository::from_git_request(parts, self).await?; 189 + 190 + if !self.can_push(repository.repository_key(), &auth.iss).await { 191 + tracing::error!(did = %auth.iss, "push denied"); 192 + return Err(Error::forbidden( 193 + self, 194 + format!( 195 + "'{}' does not have permission to push to this repository", 196 + auth.iss 197 + ), 198 + ))?; 199 + } 200 + 201 + let nonce_seed = self.generate_push_seed(repository.repository_key()); 202 + let mut command = repository.git(); 203 + command 204 + .env(private::ENV_USER_DID, auth.iss.as_str()) 205 + .option_env("X_REQUEST_ID", request_id) 206 + .args([ 207 + "-c", 208 + &nonce_seed, 209 + "receive-pack", 210 + "--http-backend-info-refs", 211 + "--stateless-rpc", 212 + ]) 213 + .arg(repository.path()); 214 + 215 + Ok(command.into()) 216 + } 217 + 218 + async fn init_receive_pack( 219 + &self, 220 + parts: &mut Parts, 221 + ) -> Result<tokio::process::Command, Self::Rejection> { 222 + let GitAuthorization(auth) = GitAuthorization::from_request_parts(parts, self).await?; 223 + let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 224 + let repository = TangledRepository::from_git_request(parts, self).await?; 225 + 226 + if !self.can_push(repository.repository_key(), &auth.iss).await { 227 + tracing::error!(did = %auth.iss, "push denied"); 228 + return Err(Error::forbidden( 229 + self, 230 + format!( 231 + "'{}' does not have permission to push to this repository", 232 + auth.iss 233 + ), 234 + ))?; 235 + } 236 + 237 + let allowed_signers_path = std::env::current_dir() 238 + .unwrap() 239 + .join("allowed_signers") 240 + .join(auth.iss.as_str()); 241 + 242 + let mut allowed_signers_option = OsString::with_capacity( 243 + "gpg.ssh.allowedSignersFile=".len() + allowed_signers_path.as_os_str().len(), 244 + ); 245 + allowed_signers_option.push("gpg.ssh.allowedSignersFile="); 246 + allowed_signers_option.push(&allowed_signers_path); 247 + 248 + let nonce_seed = self.generate_push_seed(repository.repository_key()); 249 + let mut command = repository.git(); 250 + command 251 + .env(private::ENV_USER_DID, auth.iss.as_str()) 252 + .option_env("X_REQUEST_ID", request_id) 253 + .args(["-c", &nonce_seed, "-c"]) 254 + .arg(&allowed_signers_option) 255 + .args(["receive-pack", "--stateless-rpc"]) 256 + .arg(repository.path()); 257 + 258 + Ok(command.into()) 259 + } 260 + }
+98
crates/gordian-knot/src/model/config.rs
··· 1 + //! Knot configuration. 2 + use gix::bstr::BString; 3 + use gordian_types::{Did, OwnedDid}; 4 + use rustc_hash::FxHashSet; 5 + use std::{ 6 + path::{Path, PathBuf}, 7 + time::Duration, 8 + }; 9 + 10 + pub const DEFAULT_READMES: &[&[u8]] = &[ 11 + b"README.md", 12 + b"readme.md", 13 + b"README", 14 + b"readme", 15 + b"README.markdown", 16 + b"readme.markdown", 17 + b"README.txt", 18 + b"readme.txt", 19 + b"README.rst", 20 + b"readme.rst", 21 + b"README.org", 22 + b"readme.org", 23 + b"README.asciidoc", 24 + b"readme.asciidoc", 25 + b"index.rst", 26 + ]; 27 + 28 + #[derive(Debug)] 29 + pub struct KnotConfiguration { 30 + pub owner: OwnedDid, 31 + pub instance: OwnedDid, 32 + pub repo_path: PathBuf, 33 + pub git_config: PathBuf, 34 + pub readmes: FxHashSet<BString>, 35 + pub repo_cache: RepoCacheConfig, 36 + } 37 + 38 + #[derive(Default, Debug)] 39 + pub struct RepoCacheConfig { 40 + pub size: u64, 41 + pub idle: Duration, 42 + pub live: Duration, 43 + } 44 + 45 + impl KnotConfiguration { 46 + pub fn new<P>(owner: OwnedDid, instance: OwnedDid, base: P) -> Self 47 + where 48 + P: AsRef<Path>, 49 + { 50 + assert_eq!(instance.method(), "web", "knot instance should be did:web"); 51 + 52 + let base = base.as_ref(); 53 + Self { 54 + owner, 55 + instance, 56 + repo_path: base.to_path_buf(), 57 + git_config: base.join("git_config").to_path_buf(), 58 + readmes: DEFAULT_READMES 59 + .iter() 60 + .map(|readme| BString::new(readme.to_vec())) 61 + .collect(), 62 + repo_cache: RepoCacheConfig::default(), 63 + } 64 + } 65 + 66 + #[inline] 67 + pub fn instance_ident(&self) -> &str { 68 + self.instance().ident() 69 + } 70 + 71 + /// The DID of the knot. 72 + #[inline] 73 + pub fn instance(&self) -> &Did { 74 + assert_eq!(self.instance.method(), "web"); 75 + &self.instance 76 + } 77 + 78 + /// The DID of the knot owner. 79 + #[inline] 80 + pub fn owner(&self) -> &Did { 81 + &self.owner 82 + } 83 + 84 + /// Base path to the knot's repositories. 85 + #[inline] 86 + pub fn repository_base(&self) -> &Path { 87 + self.repo_path.as_path() 88 + } 89 + 90 + #[inline] 91 + pub fn git_config_path(&self) -> &Path { 92 + self.git_config.as_path() 93 + } 94 + 95 + pub fn readmes(&self) -> &FxHashSet<BString> { 96 + &self.readmes 97 + } 98 + }
+153
crates/gordian-knot/src/model/convert.rs
··· 1 + use crate::{public::xrpc::XrpcError, types::sh_tangled::repo::tags}; 2 + use data_encoding::BASE64URL; 3 + use gix::bstr::ByteSlice; 4 + use gordian_lexicon::sh_tangled::repo::{refs, tree}; 5 + use reqwest::StatusCode; 6 + use std::{borrow::Cow, collections::HashMap}; 7 + use time::{OffsetDateTime, error::ComponentRange}; 8 + 9 + #[derive(Debug, thiserror::Error)] 10 + pub enum ConversionError { 11 + #[error("Failed to decode object: {0}")] 12 + Decode(#[from] gix::objs::decode::Error), 13 + #[error("Failed to parse git time: {0}")] 14 + TimeParse(#[from] gix::error::ParseError), 15 + #[error("Failed to convert git time: {0}")] 16 + TimeConversion(#[from] time::error::ComponentRange), 17 + } 18 + 19 + impl From<ConversionError> for XrpcError { 20 + fn from(value: ConversionError) -> Self { 21 + Self { 22 + status: StatusCode::INTERNAL_SERVER_ERROR, 23 + error: "RepositoryError".into(), 24 + message: value.to_string().into(), 25 + } 26 + } 27 + } 28 + 29 + /// Convert a git timestamp to an [`OffsetDateTime`]. 30 + pub fn time_to_offsetdatetime(time: &gix::date::Time) -> Result<OffsetDateTime, ComponentRange> { 31 + use time::UtcOffset; 32 + 33 + let odt = OffsetDateTime::from_unix_timestamp(time.seconds)? 34 + .to_offset(UtcOffset::from_whole_seconds(time.offset)?); 35 + 36 + Ok(odt) 37 + } 38 + 39 + pub fn try_convert_commit(value: gix::Commit<'_>) -> Result<refs::Commit, ConversionError> { 40 + let id = value.id().detach(); 41 + let decoded = value.decode()?; 42 + let mut merge_tag = String::default(); 43 + let mut extra_headers = HashMap::default(); 44 + for (key, value) in decoded.extra_headers.iter() { 45 + match key.as_bytes() { 46 + b"mergetag" => merge_tag = value.to_string(), 47 + b"gpgsig" => {} 48 + _ => { 49 + extra_headers.insert(key.to_string(), BASE64URL.encode(value.as_bytes())); 50 + } 51 + } 52 + } 53 + 54 + Ok(refs::Commit { 55 + hash: id.into(), 56 + author: try_convert_signature(decoded.author()?)?, 57 + committer: try_convert_signature(decoded.committer()?)?, 58 + merge_tag, 59 + pgp_signature: value.signature()?.map(|(sig, _)| sig.to_string()), 60 + message: decoded.message.to_string(), 61 + tree_hash: value.tree_id()?.detach().into(), 62 + parent_hashes: value.parent_ids().map(|id| id.detach().into()).collect(), 63 + // @TODO Review encoding parameter 64 + encoding: Cow::Borrowed("UTF-8"), 65 + extra_headers, 66 + }) 67 + } 68 + 69 + pub fn convert_entry(value: gix::object::tree::EntryRef<'_, '_>) -> tree::TreeEntry { 70 + use gix::objs::tree::EntryKind; 71 + 72 + let name = value.filename().to_string(); 73 + let mode = value.mode(); 74 + 75 + // Replicate the file mode values that go-git uses. 76 + let mode_string = match mode.kind() { 77 + EntryKind::Tree => "0040000", 78 + EntryKind::Blob => "0100644", 79 + EntryKind::BlobExecutable => "0100755", 80 + EntryKind::Link => "0120000", 81 + EntryKind::Commit => "0160000", 82 + }; 83 + 84 + tree::TreeEntry { 85 + mode: mode_string, 86 + name, 87 + ..Default::default() 88 + } 89 + } 90 + 91 + pub fn convert_reference(value: &gix::Reference<'_>) -> refs::Reference { 92 + refs::Reference { 93 + name: value.name().shorten().to_string(), 94 + hash: value.id().detach().into(), 95 + } 96 + } 97 + 98 + pub fn try_convert_signature( 99 + signature: gix::actor::SignatureRef<'_>, 100 + ) -> Result<refs::Signature, ConversionError> { 101 + let signature = signature.trim(); 102 + Ok(refs::Signature { 103 + name: signature.name.to_string(), 104 + email: signature.email.to_string(), 105 + when: time_to_offsetdatetime(&signature.time()?)?, 106 + }) 107 + } 108 + 109 + impl TryFrom<gix::Tag<'_>> for tags::TagAnnotation { 110 + type Error = gix::objs::decode::Error; 111 + 112 + fn try_from(value: gix::Tag<'_>) -> Result<Self, Self::Error> { 113 + use gix::object::Kind; 114 + 115 + let hash = value.id.into(); 116 + let decoded = value.decode()?; 117 + 118 + // cf. <https://github.com/git/git/blob/7014b55638da979331baf8dc31c4e1d697cf2d67/object.h#L97> 119 + let target_type = match decoded.target_kind { 120 + Kind::Commit => 1, 121 + Kind::Tree => 2, 122 + Kind::Blob => 3, 123 + Kind::Tag => 4, 124 + }; 125 + 126 + Ok(tags::TagAnnotation { 127 + hash, 128 + name: decoded.name.to_string(), 129 + tagger: decoded 130 + .tagger()? 131 + .and_then(|tagger| try_convert_signature(tagger).ok()), 132 + message: decoded.message.to_string(), 133 + pgp_signature: decoded.pgp_signature.map(ToString::to_string), 134 + target_type, 135 + target: decoded.target().into(), 136 + }) 137 + } 138 + } 139 + 140 + impl TryFrom<gix::Reference<'_>> for tags::Tag { 141 + type Error = gix::objs::decode::Error; 142 + 143 + fn try_from(mut value: gix::Reference<'_>) -> Result<Self, Self::Error> { 144 + let r#ref: refs::Reference = convert_reference(&value); 145 + let annotation = value 146 + .peel_to_tag() 147 + .ok() 148 + .map(TryFrom::try_from) 149 + .transpose()?; 150 + 151 + Ok(tags::Tag { r#ref, annotation }) 152 + } 153 + }
+528
crates/gordian-knot/src/model/knot_state.rs
··· 1 + use std::{ 2 + collections::HashMap, 3 + io::{self, ErrorKind}, 4 + net::SocketAddr, 5 + ops, 6 + path::PathBuf, 7 + process::Stdio, 8 + sync::{Arc, Mutex}, 9 + time::Duration, 10 + }; 11 + 12 + use futures_util::{FutureExt, future::BoxFuture}; 13 + use gordian_auth::jwt; 14 + use gordian_identity::{HttpClient, Resolver}; 15 + use gordian_lexicon::{ 16 + com::atproto::repo::list_records::Record, 17 + sh_tangled::{git::RefUpdate, repo::Repo}, 18 + }; 19 + use gordian_types::{Did, aturi::AtUri}; 20 + use moka::future::{Cache, CacheBuilder}; 21 + use rayon::{ThreadPool, ThreadPoolBuilder}; 22 + use serde::Serialize; 23 + use time::OffsetDateTime; 24 + use tokio::process::Command; 25 + use url::Url; 26 + 27 + use crate::{ 28 + release_or_debug, 29 + services::{ 30 + atrepo, 31 + authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 32 + database::{DataStore, DataStoreError}, 33 + }, 34 + types::{ 35 + RecordKey, 36 + repository_key::RepositoryKey, 37 + repository_path::{self, RepositoryPath}, 38 + }, 39 + }; 40 + 41 + use super::config::KnotConfiguration; 42 + 43 + /// Default number of ({handle,did},{name,rkey}) -> (did,rkey) mappings to 44 + /// keep in cache. 45 + const REPO_KEY_CACHE_SIZE: u64 = 16; 46 + 47 + #[derive(Clone, Debug, Serialize)] 48 + #[serde(tag = "$type")] 49 + pub enum Event { 50 + #[serde(rename = "sh.tangled.git.refUpdate")] 51 + RefUpdate(Arc<RefUpdate>), 52 + } 53 + 54 + impl Event { 55 + pub const fn collection(&self) -> &'static str { 56 + match self { 57 + Self::RefUpdate(_) => "sh.tangled.git.refUpdate", 58 + } 59 + } 60 + } 61 + 62 + #[derive(Debug)] 63 + pub struct KnotState { 64 + pub config: KnotConfiguration, 65 + 66 + /// Identity resolver. 67 + resolver: Resolver, 68 + 69 + /// Reqwest client. 70 + /// 71 + // @TODO Wrap this so prevent requests to sensitive/private endpoints. 72 + http: HttpClient, 73 + 74 + database: DataStore, 75 + 76 + /// Thread pool for running synchronous tasks. 77 + pool: ThreadPool, 78 + 79 + events: tokio::sync::broadcast::Sender<(i64, OffsetDateTime, Event)>, 80 + 81 + repo_key_cache: Cache<RepositoryPath, RepositoryKey>, 82 + 83 + repo_cache: Cache<RepositoryKey, gix::ThreadSafeRepository>, 84 + 85 + repo_mutex: Mutex<HashMap<RepositoryKey, Arc<tokio::sync::Mutex<()>>>>, 86 + 87 + push_seed: Mutex<HashMap<RepositoryKey, Box<str>>>, 88 + 89 + private_addrs: String, 90 + } 91 + 92 + impl KnotState { 93 + pub fn new<'a>( 94 + config: KnotConfiguration, 95 + resolver: Resolver, 96 + http: HttpClient, 97 + database: DataStore, 98 + private_binds: impl IntoIterator<Item = &'a SocketAddr>, 99 + ) -> io::Result<Arc<Self>> { 100 + let pool = ThreadPoolBuilder::new() 101 + .build() 102 + .expect("Failed to build thread pool"); 103 + 104 + let (events, _) = tokio::sync::broadcast::channel(16); 105 + 106 + let private_addrs = private_binds 107 + .into_iter() 108 + .map(|socket| format!("http://{socket}/")) 109 + .collect::<Vec<_>>() 110 + .join(","); 111 + 112 + let repo_cache = CacheBuilder::new(config.repo_cache.size) 113 + .name("repository_cache") 114 + .initial_capacity(config.repo_cache.size.try_into().unwrap()) 115 + .time_to_idle(config.repo_cache.idle) 116 + .time_to_live(config.repo_cache.live) 117 + .build(); 118 + 119 + let inner = Arc::new(Self { 120 + config, 121 + http, 122 + resolver, 123 + database, 124 + pool, 125 + events, 126 + repo_key_cache: CacheBuilder::new(REPO_KEY_CACHE_SIZE) 127 + .name("repository_key_cache") 128 + .time_to_idle(Duration::from_secs(60)) 129 + .build(), 130 + repo_cache, 131 + repo_mutex: Default::default(), 132 + push_seed: Default::default(), 133 + private_addrs, 134 + }); 135 + 136 + Ok(inner) 137 + } 138 + 139 + /// Return a reference to the identity resolver. 140 + #[inline] 141 + pub fn resolver(&self) -> &Resolver { 142 + &self.resolver 143 + } 144 + 145 + pub fn http(&self) -> &HttpClient { 146 + &self.http 147 + } 148 + 149 + /// Return a reference the sync thread pool. 150 + #[inline] 151 + pub(crate) fn pool(&self) -> &ThreadPool { 152 + &self.pool 153 + } 154 + 155 + pub(crate) fn subscribe_events( 156 + &self, 157 + ) -> tokio::sync::broadcast::Receiver<(i64, OffsetDateTime, Event)> { 158 + self.events.subscribe() 159 + } 160 + 161 + pub(crate) async fn send_event(&self, id: i64, ts: OffsetDateTime, event: Event) { 162 + if self.events.send((id, ts, event)).is_err() { 163 + tracing::warn!("no external listeners to consume events"); 164 + } 165 + } 166 + 167 + /// Get a reference to the database. 168 + #[inline] 169 + pub fn database(&self) -> &DataStore { 170 + &self.database 171 + } 172 + 173 + pub fn private_endpoints(&self) -> &str { 174 + &self.private_addrs 175 + } 176 + 177 + pub fn get_repo_mutex(&self, repo_key: &RepositoryKey) -> Arc<tokio::sync::Mutex<()>> { 178 + Arc::clone( 179 + self.repo_mutex 180 + .lock() 181 + .expect("mutex should not be poisoned") 182 + .entry(repo_key.clone()) 183 + .or_default(), 184 + ) 185 + } 186 + 187 + /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key (did, rkey). 188 + /// 189 + pub async fn resolve_repo_key( 190 + &self, 191 + repo_path: &RepositoryPath, 192 + ) -> Result<RepositoryKey, RepositoryResolveError> { 193 + use std::borrow::Cow; 194 + 195 + let resolved_repo_path = self 196 + .repo_key_cache 197 + .try_get_with_by_ref(repo_path, async { 198 + let owner = match Did::parse(repo_path.owner()) { 199 + Ok(did) => Cow::Borrowed(did), 200 + Err(_) => { 201 + // Assume the repo owner is a handle. 202 + let (did, _) = self.resolver().resolve(repo_path.owner()).await?; 203 + Cow::Owned(did) 204 + } 205 + }; 206 + 207 + let (rkey, _) = self 208 + .database() 209 + .resolve_repository(&owner, repo_path.name()) 210 + .await? 211 + .ok_or(RepositoryResolveError::NotFound)?; 212 + 213 + let resolved_repo_path = RepositoryKey { 214 + owner: owner.into_owned(), 215 + rkey, 216 + }; 217 + 218 + Ok::<_, RepositoryResolveError>(resolved_repo_path) 219 + }) 220 + .await 221 + .map_err(Arc::unwrap_or_clone)?; 222 + 223 + Ok(resolved_repo_path) 224 + } 225 + 226 + fn path_for_repository(&self, repo_key: &RepositoryKey) -> PathBuf { 227 + self.repository_base() 228 + .join(repo_key.owner_str()) 229 + .join(repo_key.rkey()) 230 + } 231 + 232 + pub async fn open_repository( 233 + &self, 234 + repo_key: &RepositoryKey, 235 + ) -> Result<gix::ThreadSafeRepository, Arc<gix::open::Error>> { 236 + use gix::open::Options; 237 + 238 + let repository = self 239 + .repo_cache 240 + .try_get_with_by_ref(repo_key, async { 241 + let path = self.path_for_repository(repo_key); 242 + tracing::debug!(?path, "opening repository"); 243 + Options::default() 244 + .strict_config(true) 245 + .open_path_as_is(true) 246 + .open(path) 247 + }) 248 + .await?; 249 + 250 + Ok(repository) 251 + } 252 + 253 + pub async fn can_push(&self, repo: &RepositoryKey, did: &Did) -> bool { 254 + use crate::services::rbac::{Action, Policy, PolicyResult, RepositoryPushPolicy}; 255 + let policy = RepositoryPushPolicy; 256 + let result = policy 257 + .evaluate_access(&did, &Action::RepositoryPush, repo, self) 258 + .await; 259 + 260 + matches!(result, PolicyResult::Granted) 261 + } 262 + 263 + pub async fn create_repo(&self, rec: &RecordKey<'_>, repo: &Repo<'_>) -> anyhow::Result<()> { 264 + let &RecordKey { 265 + did, 266 + collection, 267 + rkey, 268 + rev, 269 + cid, 270 + } = rec; 271 + 272 + assert_eq!(collection, "sh.tangled.repo"); 273 + assert_eq!(repo.knot, self.instance_ident()); 274 + 275 + let repo_key = RepositoryKey::new(did, rkey)?; 276 + repository_path::validate(&repo.name)?; 277 + 278 + // We're going to receive the jetstream event and the xrpc request. 279 + // 280 + // If the other is already in progress, wait here. The database insert should return 281 + // Ok(false), and repository creation will be skipped. 282 + let _guard = self.get_repo_mutex(&repo_key).lock_owned().await; 283 + 284 + let mut tx = self.database().begin().await?; 285 + let is_new = tx.insert_repository(did, rkey, rev, cid, repo).await?; 286 + 287 + if !is_new { 288 + return Ok(()); 289 + } 290 + 291 + match &repo.source { 292 + Some(source) => self.fork_repo(&repo_key, &repo.name, source).await?, 293 + None => self.init_repo(&repo_key, &repo.name)?, 294 + } 295 + 296 + tx.commit().await?; 297 + 298 + Ok(()) 299 + } 300 + 301 + pub fn init_repo(&self, repo_key: &RepositoryKey, name: &str) -> anyhow::Result<()> { 302 + repository_path::validate(&repo_key.owner)?; 303 + repository_path::validate(&repo_key.rkey)?; 304 + repository_path::validate(name)?; 305 + 306 + let path = self.path_for_repository(repo_key); 307 + 308 + if let Some(parent) = path.parent() { 309 + match std::fs::create_dir(parent) { 310 + Ok(()) => tracing::info!( 311 + ?parent, 312 + ?repo_key, 313 + "created parent directory for repository" 314 + ), 315 + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} 316 + Err(error) => { 317 + tracing::error!( 318 + ?error, 319 + ?parent, 320 + ?repo_key, 321 + "failed to create parent directory for repository" 322 + ) 323 + } 324 + } 325 + } 326 + 327 + let repo = gix::init_bare(&path)?; 328 + tracing::info!(?repo, "created repository"); 329 + 330 + // Create a symlink to map the repository name -> rkey. 331 + let symlink_path = path 332 + .parent() 333 + .expect("parent for repository path") 334 + .join(name); 335 + 336 + let _ = std::fs::remove_file(&symlink_path); 337 + std::os::unix::fs::symlink(&repo_key.rkey, &symlink_path)?; 338 + 339 + Ok(()) 340 + } 341 + 342 + #[tracing::instrument(skip(self), ret)] 343 + pub async fn fork_repo( 344 + &self, 345 + repo_key: &RepositoryKey, 346 + name: &str, 347 + source: &str, 348 + ) -> anyhow::Result<()> { 349 + // Release build: only clone over https; Debug builds: try https then http. 350 + release_or_debug!(const CLONE_SCHEMES: &[&str] = &["https"], &["https", "http"]); 351 + 352 + let path = self.path_for_repository(repo_key); 353 + tracing::debug!(?path, "forking into"); 354 + 355 + let clone_urls: Vec<_> = match AtUri::parse(source) { 356 + Ok(source_uri) => { 357 + let source_did = source_uri.did().ok_or(anyhow::anyhow!( 358 + "source repository record uri does not contain a did authority " 359 + ))?; 360 + let source_rkey = source_uri.rkey.ok_or(anyhow::anyhow!( 361 + "source repository record uri does not contain a rkey" 362 + ))?; 363 + 364 + // Fetch repository record from pds. 365 + let response = atrepo::fetch_record_bytes( 366 + self.resolver(), 367 + self.http(), 368 + source_did, 369 + "sh.tangled.repo", 370 + source_rkey, 371 + ) 372 + .await?; 373 + 374 + let record = serde_json::from_slice::<Record>(&response)?; 375 + let repo: Repo = serde_json::from_str(record.value.get())?; 376 + 377 + CLONE_SCHEMES 378 + .iter() 379 + .map(|scheme| format!("{scheme}://{}/{source_did}/{}", repo.knot, repo.name)) 380 + .collect() 381 + } 382 + Err(_) => match Url::parse(source) { 383 + Ok(url) if CLONE_SCHEMES.contains(&url.scheme()) => vec![source.to_string()], 384 + _ => return Err(anyhow::anyhow!("Unrecognised URL: {source}")), 385 + }, 386 + }; 387 + 388 + for clone_url in clone_urls { 389 + tracing::info!("forking repo from '{clone_url}'"); 390 + let output = Command::new("/usr/bin/git") 391 + .env_clear() 392 + .args(["clone", "--bare", &clone_url]) 393 + .arg(&path) 394 + .stdout(Stdio::inherit()) 395 + .stderr(Stdio::inherit()) 396 + .output() 397 + .await?; 398 + 399 + if output.status.success() { 400 + // Create a symlink to map the repository name -> rkey. 401 + let symlink_path = path 402 + .parent() 403 + .expect("parent for repository path") 404 + .join(name); 405 + 406 + let _ = std::fs::remove_file(&symlink_path); 407 + let _ = std::os::unix::fs::symlink(&repo_key.rkey, &symlink_path); 408 + 409 + return Ok(()); 410 + } else { 411 + tracing::error!(?clone_url, ?path, "git clone failed"); 412 + } 413 + } 414 + 415 + tracing::error!("all clone attempts failed"); 416 + Err(anyhow::anyhow!("failed to fork repo")) 417 + } 418 + 419 + pub async fn delete_repo(&self, rec: &RecordKey<'_>) -> anyhow::Result<()> { 420 + let mut tx = self.database().begin().await?; 421 + let Some((_, name)) = tx.delete_repository(rec).await? else { 422 + tracing::error!(?rec, "Failed to delete repository"); 423 + return Err(anyhow::anyhow!("Repository does not exist")); 424 + }; 425 + 426 + let path = self.repository_base().join(rec.did.as_str()).join(rec.rkey); 427 + let mut delete_path = self.repository_base().join("deleted"); 428 + let _ = std::fs::create_dir_all(&delete_path); 429 + delete_path.push(format!("{}-{}", rec.did, rec.rkey)); 430 + std::fs::rename(&path, &delete_path)?; 431 + 432 + // Clear from cache. 433 + self.repo_key_cache 434 + .remove(&RepositoryPath::from_parts(rec.did.as_str(), rec.rkey).unwrap()) 435 + .await; 436 + 437 + self.repo_key_cache 438 + .remove(&RepositoryPath::from_parts(rec.did.as_str(), name).unwrap()) 439 + .await; 440 + 441 + tx.commit().await?; 442 + Ok(()) 443 + } 444 + 445 + /// Get or generate a new nonce seed for signed pushes to the specified repository. 446 + pub fn generate_push_seed(&self, repository: &RepositoryKey) -> Box<str> { 447 + const PUSH_SEED_NONCE_LEN: usize = 16; 448 + 449 + let mut push_seeds = self.push_seed.lock().unwrap(); 450 + if let Some(seed) = push_seeds.get(repository).cloned() { 451 + return seed; 452 + } 453 + 454 + let mut option = "receive.certNonceSeed=".to_owned(); 455 + 456 + let raw_seed: [u8; PUSH_SEED_NONCE_LEN] = rand::random(); 457 + data_encoding::BASE32_NOPAD_VISUAL.encode_append(&raw_seed, &mut option); 458 + 459 + let encoded: Box<str> = option.into(); 460 + push_seeds.insert(repository.clone(), encoded.clone()); 461 + encoded 462 + } 463 + } 464 + 465 + #[derive(Clone, Debug, thiserror::Error)] 466 + pub enum RepositoryResolveError { 467 + #[error("Failed to resolve identity: {0}")] 468 + Resolve(#[from] gordian_identity::ResolveError), 469 + #[error("Failed to lookup respository: {0}")] 470 + Lookup(Arc<DataStoreError>), 471 + #[error("Repository not found")] 472 + NotFound, 473 + } 474 + 475 + impl From<DataStoreError> for RepositoryResolveError { 476 + fn from(value: DataStoreError) -> Self { 477 + Self::Lookup(Arc::new(value)) 478 + } 479 + } 480 + 481 + impl AuthorizationClaimsStore<jwt::Claims> for KnotState { 482 + fn get_unexpired_claims<'a: 'b, 'b>( 483 + &'a self, 484 + jti: &'b str, 485 + now: i64, 486 + ) -> BoxFuture<'b, Result<Option<jwt::Claims>, AuthorizationClaimsStoreError>> { 487 + async move { 488 + let claims = self.database().get_claims(jti, now).await.ok().flatten(); 489 + 490 + // If the claims have expired, remove them. 491 + if matches!(&claims, Some(claims) if claims.exp < now) { 492 + self.database() 493 + .delete_claims(jti) 494 + .await 495 + .map_err(|error| AuthorizationClaimsStoreError(error.into()))?; 496 + 497 + return Ok(None); 498 + } 499 + 500 + Ok(claims) 501 + } 502 + .boxed() 503 + } 504 + 505 + fn store_claims( 506 + &self, 507 + claims: jwt::Claims, 508 + now: i64, 509 + ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>> { 510 + async move { 511 + self.database() 512 + .store_claims(claims, now) 513 + .await 514 + .map_err(|error| AuthorizationClaimsStoreError(error.into()))?; 515 + 516 + Ok(()) 517 + } 518 + .boxed() 519 + } 520 + } 521 + 522 + impl ops::Deref for KnotState { 523 + type Target = KnotConfiguration; 524 + #[inline] 525 + fn deref(&self) -> &Self::Target { 526 + &self.config 527 + } 528 + }
+913
crates/gordian-knot/src/model/repository.rs
··· 1 + mod merge_check; 2 + 3 + use core::{error, fmt}; 4 + use std::{ 5 + collections::{BTreeMap, HashSet, VecDeque}, 6 + io, ops, 7 + path::{Path, PathBuf}, 8 + process::{Command, Stdio}, 9 + }; 10 + 11 + use axum::{ 12 + Json, 13 + extract::{FromRef, FromRequestParts}, 14 + }; 15 + use git_service::util::{SetOptionArg, SetOptionEnv as _}; 16 + use gix::{ 17 + ObjectId, 18 + bstr::{BString, ByteSlice}, 19 + submodule::config::Branch, 20 + }; 21 + use gordian_lexicon::sh_tangled::repo::{ 22 + blob::Submodule, branch, get_default_branch, languages, refs, tree, 23 + }; 24 + use rustc_hash::FxHashSet; 25 + use serde::Deserialize; 26 + 27 + use crate::{ 28 + model::{convert, errors, nicediff}, 29 + public::xrpc::{XrpcError, XrpcQuery, XrpcResponse, XrpcResult}, 30 + types::{ 31 + repository_key::RepositoryKey, 32 + repository_path::RepositoryPath, 33 + sh_tangled::repo::{branches, compare, diff, log, tags}, 34 + }, 35 + }; 36 + 37 + use super::Knot; 38 + 39 + #[derive(Debug)] 40 + pub struct TangledRepository { 41 + knot: Knot, 42 + repo_key: RepositoryKey, 43 + repository: gix::Repository, 44 + } 45 + 46 + impl From<(Knot, RepositoryKey, gix::Repository)> for TangledRepository { 47 + fn from((knot, repo_key, repository): (Knot, RepositoryKey, gix::Repository)) -> Self { 48 + Self { 49 + knot, 50 + repo_key, 51 + repository, 52 + } 53 + } 54 + } 55 + 56 + #[derive(Debug, thiserror::Error)] 57 + #[error(transparent)] 58 + pub struct TreeError(#[from] gix::object::commit::Error); 59 + 60 + #[derive(Debug, thiserror::Error)] 61 + pub enum BlobError { 62 + #[error(transparent)] 63 + PathLookupError(#[from] gix::object::find::existing::Error), 64 + #[error("File not found at the specified path")] 65 + FileNotFound, 66 + #[error("Requested path is not a blob")] 67 + NotABlob, 68 + } 69 + 70 + impl TangledRepository { 71 + pub fn path(&self) -> &Path { 72 + self.repository.path() 73 + } 74 + 75 + pub fn repository_key(&self) -> &RepositoryKey { 76 + &self.repo_key 77 + } 78 + 79 + /// Resolve a revspec into a [`(gix::Commit, bool)`] tuple. 80 + /// 81 + /// The boolean value indicates whether the revspec is immutable (ie. if 82 + /// it is an object ID). 83 + fn resolve_revspec(&self, revspec: Option<&str>) -> Result<(gix::Commit<'_>, bool), XrpcError> { 84 + use std::str::FromStr as _; 85 + 86 + Ok(if let Some(refspec) = revspec { 87 + match gix::ObjectId::from_str(refspec) { 88 + Ok(id) => ( 89 + self.repository 90 + .find_commit(id) 91 + .map_err(errors::RefNotFound)?, 92 + true, 93 + ), 94 + Err(_) => { 95 + // Assume the revspec is a branch or tag. 96 + let mut reference = self 97 + .repository 98 + .find_reference(refspec) 99 + .map_err(errors::RefNotFound)?; 100 + ( 101 + reference.peel_to_commit().map_err(errors::RefNotFound)?, 102 + false, 103 + ) 104 + } 105 + } 106 + } else { 107 + ( 108 + self.repository.head_commit().map_err(errors::RefNotFound)?, 109 + false, 110 + ) 111 + }) 112 + } 113 + 114 + pub fn get_tree<'repo>( 115 + &self, 116 + commit: &gix::Commit<'repo>, 117 + ) -> Result<gix::Tree<'repo>, TreeError> { 118 + Ok(commit.tree()?) 119 + } 120 + 121 + pub fn get_blob(&self, tree: &gix::Tree<'_>, path: &Path) -> Result<Vec<u8>, BlobError> { 122 + let entry = tree 123 + .lookup_entry_by_path(path)? 124 + .ok_or(BlobError::FileNotFound)?; 125 + 126 + if !(entry.mode().is_blob() || entry.mode().is_link()) { 127 + return Err(BlobError::NotABlob); 128 + } 129 + 130 + Ok(entry.object()?.into_blob().take_data()) 131 + } 132 + 133 + pub fn branch(&self, params: branch::Input) -> XrpcResult<Json<branch::Output>> { 134 + let mut reference = self 135 + .repository 136 + .find_reference(&params.name) 137 + .map_err(errors::RefNotFound)?; 138 + 139 + let commit = reference.peel_to_commit().map_err(errors::RefNotFound)?; 140 + let name = reference.name().shorten().to_string(); 141 + let hash = commit.id.into(); 142 + let time = commit 143 + .committer() 144 + .map_err(errors::RepoError)? 145 + .time() 146 + .map_err(errors::RepoError)?; 147 + 148 + let when = convert::time_to_offsetdatetime(&time).map_err(errors::RepoError)?; 149 + let author = convert::try_convert_signature(commit.author().map_err(errors::RepoError)?)?; 150 + let message = commit 151 + .message() 152 + .map_err(errors::RepoError)? 153 + .summary() 154 + .to_string(); 155 + 156 + // Assume HEAD points to the intended default branch. This *should* be 157 + // true for a bare repository. 158 + let head = self.repository.head()?; 159 + let default_name = head 160 + .referent_name() 161 + .ok_or(errors::HeadDetached)? 162 + .shorten() 163 + .to_string(); 164 + 165 + let is_default = default_name == name; 166 + 167 + Ok(Json(branch::Output { 168 + name, 169 + hash, 170 + when, 171 + author, 172 + message, 173 + is_default, 174 + }) 175 + .into()) 176 + } 177 + 178 + pub fn branches(&self, _params: branches::Input) -> XrpcResult<Json<branches::Output>> { 179 + // Assume HEAD points to the intended default branch. This *should* be 180 + // true for a bare repository. 181 + let head = self.repository.head()?; 182 + let default_name = head 183 + .referent_name() 184 + .ok_or(errors::HeadDetached)? 185 + .shorten() 186 + .to_string(); 187 + 188 + let mut branches = Vec::new(); 189 + for branch in self.repository.references()?.local_branches()? 190 + // .skip(params.cursor) 191 + // .take(_params.limit.into()) 192 + { 193 + let Ok(branch) = branch.inspect_err(|error| tracing::error!(?error)) else { 194 + continue; 195 + }; 196 + 197 + let name = branch.name().shorten().to_string(); 198 + let Some(id) = branch.try_id() else { 199 + tracing::warn!(?name, "branch unborn, skipping"); 200 + continue; 201 + }; 202 + 203 + let Ok(commit) = self.repository.find_commit(id) else { 204 + tracing::error!(?name, ?id, "failed to find commit for branch"); 205 + continue; 206 + }; 207 + 208 + let is_default = name == default_name; 209 + branches.push(branches::Branch { 210 + reference: refs::Reference { 211 + name, 212 + hash: commit.id.into(), 213 + }, 214 + commit: convert::try_convert_commit(commit)?, 215 + is_default, 216 + }); 217 + } 218 + 219 + Ok(Json(branches::Output { branches }).into()) 220 + } 221 + 222 + pub fn compare(&self, params: compare::Input) -> XrpcResult<Json<compare::Output>> { 223 + let (rev1, rev1_immutable) = self.resolve_revspec(Some(&params.rev1))?; 224 + let (rev2, rev2_immutable) = self.resolve_revspec(Some(&params.rev2))?; 225 + 226 + let mut seen = HashSet::new(); 227 + let mut commits = self 228 + .repository 229 + .rev_walk([rev2.id]) 230 + .with_hidden([rev1.id]) 231 + .selected(|oid| seen.insert(oid.to_owned())) 232 + .map_err(errors::RepoError)? 233 + .take_while(|val| { 234 + val.as_ref() 235 + .is_ok_and(|commit| commit.parent_ids.len() == 1) 236 + }) 237 + .collect::<Result<Vec<_>, _>>() 238 + .map_err(errors::RepoError)?; 239 + 240 + commits.reverse(); 241 + 242 + let mut format_patch_raw = String::new(); 243 + for commit in commits { 244 + let output = self 245 + .git() 246 + .arg("format-patch") 247 + .arg("-1") 248 + .arg(commit.id.to_hex().to_string()) 249 + .arg("--stdout") 250 + .output() 251 + .map_err(errors::Internal)?; 252 + 253 + format_patch_raw.push_str(&output.stdout.to_str_lossy()); 254 + format_patch_raw.push('\n'); 255 + } 256 + 257 + Ok(XrpcResponse { 258 + response: Json(compare::Output { 259 + rev1: rev1.id.into(), 260 + rev2: rev2.id.into(), 261 + format_patch_raw, 262 + }), 263 + immutable: rev1_immutable && rev2_immutable, 264 + }) 265 + } 266 + 267 + pub fn diff(&self, params: diff::Input) -> XrpcResult<Json<diff::Output>> { 268 + let (this_commit, immutable) = self.resolve_revspec(Some(&params.rev))?; 269 + let diff = nicediff::unified_diff_from_parent(this_commit).unwrap(); 270 + let response = diff::Output { 271 + rev: params.rev.into(), 272 + diff, 273 + }; 274 + 275 + Ok(XrpcResponse { 276 + response: Json(response), 277 + immutable, 278 + }) 279 + } 280 + 281 + pub fn get_default_branch( 282 + &self, 283 + _: get_default_branch::Input, 284 + ) -> XrpcResult<Json<get_default_branch::Output>> { 285 + // Assume HEAD points the intended default branch. This *should* be true 286 + // for a bare repository. 287 + let mut head = self.repository.head()?; 288 + let name = head 289 + .referent_name() 290 + .ok_or(errors::HeadDetached)? 291 + .shorten() 292 + .to_string(); 293 + 294 + let hash = head.id().map(|id| id.detach().into()); 295 + let when = head 296 + .peel_to_commit() 297 + .ok() 298 + .and_then(|commit| { 299 + commit 300 + .committer() 301 + .ok() 302 + .and_then(|committer| committer.time().ok()) 303 + }) 304 + .and_then(|time| convert::time_to_offsetdatetime(&time).ok()); 305 + 306 + Ok(Json(get_default_branch::Output { name, hash, when }).into()) 307 + } 308 + 309 + pub fn languages(&self, _: languages::Input) -> XrpcResult<Json<languages::Output>> { 310 + Ok(Json(languages::Output::default()).into()) 311 + } 312 + 313 + pub fn log(&self, params: log::Input) -> XrpcResult<Json<log::Output>> { 314 + let commit_graph = self.repository.commit_graph_if_enabled()?; 315 + let total = match &commit_graph { 316 + Some(cg) => cg 317 + .num_commits() 318 + .try_into() 319 + .expect("You must be at least 32 bits tall to enjoy this ride"), 320 + None => { 321 + tracing::warn!(repository = ?self.repository, "no commit-graph, counting commits manually"); 322 + self.repository 323 + .rev_walk([self.repository.head_id().map_err(errors::RepoEmpty)?]) 324 + .all() 325 + .map_err(errors::RepoError)? 326 + .count() 327 + } 328 + }; 329 + 330 + let (tip, _) = self.resolve_revspec(params.rev.as_deref())?; 331 + 332 + let mut commits = Vec::new(); 333 + for commit in self 334 + .repository 335 + .rev_walk([tip.id()]) 336 + .with_commit_graph(commit_graph) 337 + .all() 338 + .map_err(errors::RepoError)? 339 + .skip(params.cursor) 340 + .take(params.limit.into()) 341 + { 342 + match commit { 343 + Ok(commit) => { 344 + let commit = self 345 + .repository 346 + .find_commit(commit.id()) 347 + .map_err(errors::RepoError)?; 348 + commits.push(convert::try_convert_commit(commit).map_err(errors::RepoError)?); 349 + } 350 + Err(error) => { 351 + tracing::error!(?error); 352 + break; 353 + } 354 + } 355 + } 356 + 357 + Ok(Json(log::Output { 358 + commits, 359 + log: true, 360 + total, 361 + page: 1 + params.cursor / usize::from(params.limit), 362 + per_page: params.limit, 363 + }) 364 + .into()) 365 + } 366 + 367 + pub fn tags(&self, _: tags::Input) -> XrpcResult<Json<tags::Output>> { 368 + use std::cmp::Reverse; 369 + 370 + let mut tags: Vec<_> = self 371 + .repository 372 + .references()? 373 + .tags()? 374 + .filter_map(|tag| { 375 + tag.inspect_err(|error| tracing::error!(?error)) 376 + .ok()? 377 + .try_into() 378 + .inspect_err(|error| tracing::error!(?error)) 379 + .ok() 380 + }) 381 + .collect(); 382 + 383 + tags.sort_by_key(|tag: &tags::Tag| { 384 + Reverse( 385 + tag.annotation 386 + .as_ref() 387 + .map(|an| an.tagger.as_ref().map(|tagger| tagger.when)), 388 + ) 389 + }); 390 + 391 + Ok(Json(tags::Output { tags }).into()) 392 + } 393 + 394 + pub fn tree( 395 + &self, 396 + params: tree::Input, 397 + readmes: &FxHashSet<BString>, 398 + ) -> XrpcResult<Json<tree::Output>> { 399 + let (tip, immutable) = self.resolve_revspec(params.rev.as_deref())?; 400 + let dotdot = params.path.clone().and_then(|mut path| { 401 + path.pop(); 402 + match path.as_os_str().is_empty() { 403 + true => None, 404 + false => Some(path), 405 + } 406 + }); 407 + 408 + let mut parent = None; 409 + let mut tree = tip.tree()?; 410 + if let Some(subpath) = &params.path { 411 + let entry = tree 412 + .lookup_entry_by_path(subpath)? 413 + .ok_or(errors::PathNotFound(subpath.to_string_lossy()))?; 414 + 415 + if !entry.mode().is_tree() { 416 + return Ok(XrpcResponse { 417 + response: Json(tree::Output { 418 + files: vec![], 419 + dotdot: dotdot.map(|path| path.into()), 420 + parent: params.path.map(|path| path.into()), 421 + rev: params.rev.as_deref().unwrap_or_default().to_string(), 422 + readme: None, 423 + }), 424 + immutable, 425 + }); 426 + } 427 + 428 + let subtree = self.repository.find_tree(entry.id()).unwrap(); 429 + tree = subtree; 430 + parent = Some(subpath.to_path_buf()); 431 + } 432 + 433 + let mut files: Vec<tree::TreeEntry> = vec![]; 434 + let mut readme = None; 435 + for entry in tree.iter() { 436 + let Ok(entry) = entry else { 437 + continue; 438 + }; 439 + 440 + if readmes.contains(entry.filename()) && entry.mode().is_blob() && readme.is_none() { 441 + let mut file = self.repository.find_blob(entry.id())?; 442 + if let Ok(contents) = String::from_utf8(file.take_data()) { 443 + readme.replace(tree::Readme { 444 + contents, 445 + filename: entry.filename().to_string(), 446 + }); 447 + } 448 + } 449 + 450 + files.push(convert::convert_entry(entry)); 451 + } 452 + 453 + let files: Vec<_> = tree 454 + .iter() 455 + .filter_map(|entry| { 456 + let entry = entry.ok()?; 457 + let file = convert::convert_entry(entry); 458 + Some(file) 459 + }) 460 + .collect(); 461 + 462 + Ok(XrpcResponse { 463 + response: Json(tree::Output { 464 + files, 465 + dotdot: dotdot.map(|path| path.into()), 466 + parent, 467 + rev: params.rev.as_deref().unwrap_or_default().to_string(), 468 + readme, 469 + }), 470 + immutable, 471 + }) 472 + } 473 + 474 + pub fn submodule(&self, path: &Path) -> Option<Submodule> { 475 + if let Ok(Some(submodules)) = self.repository.modules() 476 + && let Some(name) = submodules.name_by_path(path.as_os_str().as_encoded_bytes().into()) 477 + { 478 + let url = submodules.url(name).ok()?.to_string(); 479 + let branch = submodules 480 + .branch(name) 481 + .ok()? 482 + .and_then(|branch| match branch { 483 + Branch::CurrentInSuperproject => None, 484 + Branch::Name(name) => Some(name.to_string()), 485 + }); 486 + 487 + let name = name.to_string(); 488 + return Some(Submodule { name, url, branch }); 489 + } 490 + 491 + None 492 + } 493 + } 494 + 495 + impl<S: Send + Sync> FromRequestParts<S> for TangledRepository 496 + where 497 + Knot: axum::extract::FromRef<S>, 498 + { 499 + type Rejection = XrpcError; 500 + 501 + async fn from_request_parts( 502 + parts: &mut axum::http::request::Parts, 503 + state: &S, 504 + ) -> Result<Self, Self::Rejection> { 505 + #[derive(Deserialize)] 506 + struct Param { 507 + repo: String, 508 + } 509 + 510 + let XrpcQuery(Param { repo }) = XrpcQuery::from_request_parts(parts, state).await?; 511 + let repo_path: RepositoryPath = repo.parse().map_err(errors::InvalidRequest)?; 512 + 513 + let knot = Knot::from_ref(state); 514 + let repo_key = knot 515 + .resolve_repo_key(&repo_path) 516 + .await 517 + .map_err(errors::RepoNotFound)?; 518 + 519 + let repository = knot 520 + .open_repository(&repo_key) 521 + .await 522 + .map_err(errors::RepoNotFound)? 523 + .to_thread_local(); 524 + 525 + Ok(Self { 526 + knot, 527 + repo_key, 528 + repository, 529 + }) 530 + } 531 + } 532 + 533 + impl TangledRepository { 534 + pub async fn from_git_request<S: Send + Sync>( 535 + parts: &mut axum::http::request::Parts, 536 + state: &S, 537 + ) -> Result<Self, crate::public::git::Error> 538 + where 539 + Knot: axum::extract::FromRef<S>, 540 + { 541 + use crate::public::git::NotFound; 542 + use axum::extract::Path; 543 + 544 + let knot = Knot::from_ref(state); 545 + let Path(repo_path) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?; 546 + let repo_key = knot.resolve_repo_key(&repo_path).await.map_err(NotFound)?; 547 + 548 + let repository = knot 549 + .open_repository(&repo_key) 550 + .await 551 + .map_err(NotFound)? 552 + .to_thread_local(); 553 + 554 + Ok(Self { 555 + knot, 556 + repo_key, 557 + repository, 558 + }) 559 + } 560 + } 561 + 562 + impl TangledRepository { 563 + /// Initialise a [`Command`] for running git with the appropriate environment and working 564 + /// directory for the repository. 565 + /// 566 + pub fn git(&self) -> Command { 567 + use crate::private::{ENV_PRIVATE_ENDPOINTS, ENV_REPO_DID, ENV_REPO_RKEY}; 568 + 569 + let mut command = Command::new("/usr/bin/git"); 570 + command 571 + .current_dir(self.path()) 572 + .env_clear() 573 + .env("GIT_CONFIG_GLOBAL", self.knot.git_config_path()) 574 + .env(ENV_PRIVATE_ENDPOINTS, self.knot.private_endpoints()) 575 + .env(ENV_REPO_DID, self.repo_key.owner_str()) 576 + .env(ENV_REPO_RKEY, &self.repo_key.rkey); 577 + 578 + command 579 + } 580 + } 581 + 582 + /// A temporary detached worktree generated with a randomised name, and deleted when 583 + /// the object is dropped. 584 + /// 585 + #[derive(Debug)] 586 + struct TempWorktree<'repo> { 587 + repo: &'repo gix::Repository, 588 + 589 + /// Worktree name. 590 + // 591 + // Used to remove the worktree later. 592 + name: String, 593 + 594 + /// Path to the worktree. 595 + path: PathBuf, 596 + 597 + /// Path to the global git configuration file. 598 + config: Option<PathBuf>, 599 + } 600 + 601 + impl<'repo> TempWorktree<'repo> { 602 + pub fn builder() -> TempWorktreeBuilder<'repo> { 603 + TempWorktreeBuilder::new() 604 + } 605 + 606 + /// Get the absolute path to the worktree. 607 + pub fn path(&self) -> &Path { 608 + &self.path 609 + } 610 + } 611 + 612 + impl<'repo> Drop for TempWorktree<'repo> { 613 + fn drop(&mut self) { 614 + tracing::debug!(?self, "removing temporary worktree"); 615 + if let Err(error) = Command::new("/usr/bin/git") 616 + .env_clear() 617 + .current_dir(self.repo.path()) 618 + .option_env("GIT_GLOBAL_CONFIG", self.config.as_deref()) 619 + .arg("-C") 620 + .arg(self.repo.path()) 621 + .arg("worktree") 622 + .arg("remove") 623 + .arg(&self.name) 624 + .stderr(Stdio::inherit()) 625 + .stdout(Stdio::null()) 626 + .output() 627 + { 628 + tracing::error!(?self, ?error, "failed to remove temporary worktree"); 629 + } 630 + } 631 + } 632 + 633 + #[derive(Clone, Debug)] 634 + pub struct TempWorktreeBuilder<'a> { 635 + prefix: Option<&'a str>, 636 + 637 + // Commit object ID to create the worktree from. 638 + commit: Option<&'a ObjectId>, 639 + 640 + /// Path to the global git config. 641 + config: Option<&'a Path>, 642 + } 643 + 644 + impl<'a> TempWorktreeBuilder<'a> { 645 + pub const fn new() -> Self { 646 + Self { 647 + prefix: None, 648 + commit: None, 649 + config: None, 650 + } 651 + } 652 + 653 + /// Set a prefix for the randomly generated worktree name. 654 + pub const fn prefix(&mut self, prefix: &'a str) -> &mut Self { 655 + self.prefix = Some(prefix); 656 + self 657 + } 658 + 659 + /// Set a commit id to create the worktree from. 660 + pub const fn commit(&mut self, commit: &'a ObjectId) -> &mut Self { 661 + self.commit = Some(commit); 662 + self 663 + } 664 + 665 + /// Set the path to the global git config file. 666 + pub const fn config(&mut self, config: &'a Path) -> &mut Self { 667 + self.config = Some(config); 668 + self 669 + } 670 + 671 + /// Build the temporary worktree. 672 + /// 673 + /// # Errors 674 + /// 675 + /// Returns an error if the git subprocess could not be spawned or exits with a non-zero 676 + /// exit code. 677 + /// 678 + /// # Panics 679 + /// 680 + /// Panics if `repo` is not a bare repository. 681 + /// 682 + fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> { 683 + assert!(repo.is_bare(), "repository should be bare"); 684 + 685 + let mut name = 686 + String::with_capacity(self.prefix.map(|val| val.len() + 1).unwrap_or_default() + 13); 687 + 688 + let random_bytes: [u8; 8] = rand::random(); 689 + if let Some(prefix) = self.prefix { 690 + name.push_str(prefix); 691 + name.push('-'); 692 + } 693 + 694 + data_encoding::BASE32_NOPAD_VISUAL.encode_append(&random_bytes, &mut name); 695 + name = name.to_lowercase(); 696 + 697 + let commit = self.commit.map(ToString::to_string); 698 + let config = self.config; 699 + let output = Command::new("/usr/bin/git") 700 + .env_clear() 701 + .current_dir(repo.path()) 702 + .option_env("GIT_GLOBAL_CONFIG", config) 703 + .arg("-C") 704 + .arg(repo.path()) 705 + .arg("worktree") 706 + .arg("add") 707 + .arg("--detach") 708 + .arg(&name) 709 + .option_arg(commit) 710 + .stderr(Stdio::piped()) 711 + .stdout(Stdio::null()) 712 + .output()?; 713 + 714 + if !output.status.success() { 715 + let message = String::from_utf8_lossy(&output.stderr); 716 + return Err(io::Error::other(format!( 717 + "Failed to create temporary worktree: {message}" 718 + ))); 719 + } 720 + 721 + let path = repo.path().join(&name); 722 + Ok(TempWorktree { 723 + repo, 724 + name, 725 + path, 726 + config: config.map(Path::to_path_buf), 727 + }) 728 + } 729 + } 730 + 731 + #[derive(Debug)] 732 + pub struct RevspecError(pub Box<dyn error::Error + Send + Sync + 'static>); 733 + 734 + impl fmt::Display for RevspecError { 735 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 736 + fmt::Display::fmt(&self.0, f) 737 + } 738 + } 739 + 740 + impl error::Error for RevspecError {} 741 + 742 + impl From<gix::object::find::existing::with_conversion::Error> for RevspecError { 743 + fn from(value: gix::object::find::existing::with_conversion::Error) -> Self { 744 + Self(value.into()) 745 + } 746 + } 747 + 748 + impl From<gix::reference::find::existing::Error> for RevspecError { 749 + fn from(value: gix::reference::find::existing::Error) -> Self { 750 + Self(value.into()) 751 + } 752 + } 753 + 754 + impl From<gix::reference::peel::to_kind::Error> for RevspecError { 755 + fn from(value: gix::reference::peel::to_kind::Error) -> Self { 756 + Self(value.into()) 757 + } 758 + } 759 + 760 + impl From<gix::reference::head_commit::Error> for RevspecError { 761 + fn from(value: gix::reference::head_commit::Error) -> Self { 762 + Self(value.into()) 763 + } 764 + } 765 + 766 + pub struct ResolvedRevspec<'a> { 767 + /// Resolved commit. 768 + pub commit: gix::Commit<'a>, 769 + 770 + /// `true` if the revspec is immutable, for example an object ID. 771 + pub immutable: bool, 772 + } 773 + 774 + pub trait ResolveRevspec { 775 + /// Resolve a revspec into a [`ResolvedRevspec`] tuple. 776 + fn resolve_revspec<'repo, R>( 777 + &'repo self, 778 + revspec: &Option<R>, 779 + ) -> Result<ResolvedRevspec<'repo>, RevspecError> 780 + where 781 + R: ops::Deref<Target = str>; 782 + } 783 + 784 + impl ResolveRevspec for gix::Repository { 785 + fn resolve_revspec<'repo, R>( 786 + &'repo self, 787 + revspec: &Option<R>, 788 + ) -> Result<ResolvedRevspec<'repo>, RevspecError> 789 + where 790 + R: ops::Deref<Target = str>, 791 + { 792 + use std::str::FromStr as _; 793 + 794 + Ok(if let Some(refspec) = revspec.as_deref() { 795 + match gix::ObjectId::from_str(refspec) { 796 + Ok(id) => ResolvedRevspec { 797 + commit: self.find_commit(id)?, 798 + immutable: true, 799 + }, 800 + Err(_) => { 801 + // Assume the revspec is a branch or tag. 802 + let mut reference = self.find_reference(refspec)?; 803 + ResolvedRevspec { 804 + commit: reference.peel_to_commit()?, 805 + immutable: false, 806 + } 807 + } 808 + } 809 + } else { 810 + ResolvedRevspec { 811 + commit: self.head_commit()?, 812 + immutable: false, 813 + } 814 + }) 815 + } 816 + } 817 + 818 + impl ResolveRevspec for TangledRepository { 819 + fn resolve_revspec<'repo, R>( 820 + &'repo self, 821 + revspec: &Option<R>, 822 + ) -> Result<ResolvedRevspec<'repo>, RevspecError> 823 + where 824 + R: ops::Deref<Target = str>, 825 + { 826 + self.repository.resolve_revspec(revspec) 827 + } 828 + } 829 + 830 + pub trait RepositoryStatsExt { 831 + fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64>; 832 + 833 + fn language_breakdown(&self, at: &ObjectId) -> BTreeMap<String, u64>; 834 + } 835 + 836 + impl RepositoryStatsExt for gix::Repository { 837 + fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64> { 838 + let mut counts = BTreeMap::new(); 839 + 840 + let commit_graph = self.commit_graph_if_enabled().ok().flatten(); 841 + let walk = self 842 + .rev_walk([start.clone()]) 843 + .with_commit_graph(commit_graph) 844 + .with_boundary([end.clone()]) 845 + .all(); 846 + 847 + let Ok(walk) = walk else { 848 + return Default::default(); 849 + }; 850 + 851 + for commit in walk { 852 + let Ok(commit) = commit else { 853 + break; 854 + }; 855 + let Ok(commit) = commit.object() else { 856 + break; 857 + }; 858 + let Ok(author) = commit.author() else { 859 + break; 860 + }; 861 + 862 + *counts.entry(author.email.to_string()).or_default() += 1; 863 + } 864 + 865 + counts 866 + } 867 + 868 + fn language_breakdown(&self, at: &ObjectId) -> BTreeMap<String, u64> { 869 + let mut languages = BTreeMap::new(); 870 + 871 + let worktree = TempWorktree::builder() 872 + .prefix("languages-scan") 873 + .commit(at) 874 + .build(&self); 875 + 876 + let worktree = match worktree { 877 + Err(error) => { 878 + tracing::error!(?error, "error creating temporary worktree"); 879 + return languages; 880 + } 881 + Ok(worktree) => worktree, 882 + }; 883 + 884 + let mut to_scan = VecDeque::new(); 885 + to_scan.push_back(worktree.path().to_path_buf()); 886 + 887 + while let Some(dir) = to_scan.pop_front() { 888 + for entry in std::fs::read_dir(dir).unwrap() { 889 + let Ok(entry) = entry else { 890 + continue; 891 + }; 892 + 893 + let Ok(metadata) = entry.metadata() else { 894 + continue; 895 + }; 896 + 897 + if metadata.is_dir() { 898 + to_scan.push_back(entry.path()); 899 + continue; 900 + } 901 + 902 + let language = match entry.path().extension().map(|val| val.as_encoded_bytes()) { 903 + Some(b"rs") => "Rust", 904 + _ => "Other", 905 + }; 906 + 907 + *languages.entry(language.to_string()).or_default() += metadata.len(); 908 + } 909 + } 910 + 911 + languages 912 + } 913 + }
+102
crates/gordian-knot/src/model/repository/merge_check.rs
··· 1 + use std::{borrow::Cow, io::Write as _, process::Stdio}; 2 + 3 + use axum::Json; 4 + use gordian_lexicon::sh_tangled::repo::merge_check::{ConflictInfo, Output}; 5 + 6 + use crate::{ 7 + model::{ 8 + errors, 9 + repository::{ResolveRevspec as _, ResolvedRevspec, TempWorktree}, 10 + }, 11 + public::xrpc::{XrpcError, XrpcResponse}, 12 + }; 13 + 14 + impl super::TangledRepository { 15 + pub fn merge_check( 16 + &self, 17 + patch: String, 18 + branch: &str, 19 + ) -> Result<XrpcResponse<Json<Output>>, XrpcError> { 20 + let ResolvedRevspec { commit, immutable } = 21 + self.repository.resolve_revspec(&Some(branch.as_ref()))?; 22 + 23 + let worktree = TempWorktree::builder() 24 + .prefix("merge-check") 25 + .config(&self.knot.git_config_path()) 26 + .commit(&commit.id) 27 + .build(&self.repository) 28 + .map_err(errors::Internal)?; 29 + 30 + let mut child = self 31 + .git() 32 + .arg("-C") 33 + .arg(worktree.path()) 34 + .arg("apply") 35 + .arg("--check") 36 + .arg("--verbose") 37 + .arg("-") 38 + .stdin(Stdio::piped()) 39 + .stderr(Stdio::piped()) 40 + .spawn() 41 + .map_err(errors::Internal)?; 42 + 43 + let mut stdin = child.stdin.take().expect("handle present"); 44 + let writer = std::thread::spawn(move || stdin.write_all(patch.as_bytes())); 45 + let output = child.wait_with_output().map_err(errors::Internal)?; 46 + 47 + writer 48 + .join() 49 + .expect("thread should not panic") 50 + .map_err(errors::Internal)?; 51 + 52 + let errors = std::str::from_utf8(&output.stderr).map_err(errors::Internal)?; 53 + let conflicts = parse_git_apply_check_errors(errors); 54 + let is_conflicted = !output.status.success() && !conflicts.is_empty(); 55 + let message = is_conflicted.then_some(Cow::Borrowed("patch cannot be applied cleanly")); 56 + 57 + Ok(XrpcResponse { 58 + response: Json(Output { 59 + is_conflicted, 60 + conflicts, 61 + message, 62 + error: None, 63 + }), 64 + immutable, 65 + }) 66 + } 67 + } 68 + 69 + fn parse_git_apply_check_errors(stderr: &str) -> Vec<ConflictInfo> { 70 + let mut hunk_name = None; 71 + stderr 72 + .lines() 73 + .filter_map(|line| { 74 + let mut parts = line.splitn(3, ':').map(|s| s.trim()); 75 + match (parts.next(), parts.next(), parts.next()) { 76 + (Some("error"), Some("patch failed"), Some(hunk)) => { 77 + hunk_name = Some(hunk); 78 + None 79 + } 80 + (Some("error"), Some(filename), Some("already exists in working directory")) => { 81 + Some(ConflictInfo { 82 + filename: hunk_name.unwrap_or(filename).to_owned(), 83 + reason: Cow::Borrowed("file already exists"), 84 + }) 85 + } 86 + (Some("error"), Some(filename), Some("does not exist in working tree")) => { 87 + Some(ConflictInfo { 88 + filename: hunk_name.unwrap_or(filename).to_owned(), 89 + reason: Cow::Borrowed("file does not exist"), 90 + }) 91 + } 92 + (Some("error"), Some(filename), Some("patch does not apply")) => { 93 + Some(ConflictInfo { 94 + filename: hunk_name.unwrap_or(filename).to_owned(), 95 + reason: Cow::Borrowed("patch does not apply"), 96 + }) 97 + } 98 + _ => None, 99 + } 100 + }) 101 + .collect() 102 + }
+330
crates/gordian-knot/src/private.rs
··· 1 + use core::fmt; 2 + use std::{borrow::Cow, io, process::Stdio, sync::Arc}; 3 + 4 + use axum::{ 5 + extract::{FromRequestParts, Path, State}, 6 + http::{HeaderMap, StatusCode, request::Parts}, 7 + response::IntoResponse, 8 + }; 9 + use gordian_lexicon::sh_tangled::git::{ 10 + CommitCount, CommitCountBreakdown, Language, LanguageBreakdown, Meta, RefUpdate, 11 + }; 12 + use gordian_types::OwnedDid; 13 + use serde::{Deserialize, Serialize}; 14 + use time::OffsetDateTime; 15 + use tokio_rayon::AsyncThreadPool as _; 16 + 17 + use crate::{ 18 + model::{ 19 + Knot, errors, 20 + knot_state::Event, 21 + repository::{RepositoryStatsExt as _, TangledRepository}, 22 + }, 23 + public::xrpc::XrpcError, 24 + types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 25 + }; 26 + 27 + /// Environment variable containing one or more whitespace separated URLs for the internal API. 28 + /// 29 + /// By default, knot will serve the internal API on all the addresses resolved from `localhost` 30 + /// bound to a OS assigned port. 31 + /// 32 + /// # Example 33 + /// 34 + /// `"http://[::1]:44269/ http://127.0.0.1:36413/"` 35 + /// 36 + pub const ENV_PRIVATE_ENDPOINTS: &str = "GORDIAN_PRIVATE_ENDPOINTS"; 37 + 38 + /// Environment variable containing the DID of the account that triggered the hook. 39 + pub const ENV_USER_DID: &str = "GORDIAN_USER_DID"; 40 + 41 + /// Environment variable containing the DID that owns the repository the hook has be triggered on. 42 + pub const ENV_REPO_DID: &str = "GORDIAN_REPO_DID"; 43 + 44 + /// Environment variable containing the rkey of the repository the hook has be triggered on. 45 + pub const ENV_REPO_RKEY: &str = "GORDIAN_REPO_RKEY"; 46 + 47 + /// Prefix to add when converting an environment variable from the hook to a HTTP header. 48 + pub const ENV_HEADER_PREFIX: &str = "X-Gordian"; 49 + 50 + /// Build a new router for the internal API. 51 + #[rustfmt::skip] 52 + pub fn router() -> axum::Router<Knot> { 53 + use axum::routing::post; 54 + axum::Router::new() 55 + .without_v07_checks() 56 + .route("/hook/{owner}/{rkey}/pre-receive", post(hook_pre_receive)) 57 + .route("/hook/{owner}/{rkey}/post-receive", post(hook_post_receive)) 58 + .route("/hook/{owner}/{rkey}/post-update", post(hook_post_update)) 59 + } 60 + 61 + /// Hooks handled by knot. 62 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 63 + #[serde(rename_all = "kebab-case")] 64 + pub enum Hook { 65 + PostReceive, 66 + PostUpdate, 67 + PreReceive, 68 + } 69 + 70 + impl Hook { 71 + /// Get the hook name as a `&'static str`. 72 + pub fn as_str(&self) -> &'static str { 73 + match self { 74 + Self::PostReceive => "post-receive", 75 + Self::PostUpdate => "post-update", 76 + Self::PreReceive => "pre-receive", 77 + } 78 + } 79 + } 80 + 81 + impl fmt::Display for Hook { 82 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 83 + f.write_str(self.as_str()) 84 + } 85 + } 86 + 87 + /// Extracts the 'X-Gordian-User-Did' header from a request. 88 + pub struct ActorDid(pub OwnedDid); 89 + 90 + impl<S: Sync> FromRequestParts<S> for ActorDid { 91 + type Rejection = (StatusCode, Cow<'static, str>); 92 + 93 + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { 94 + macro_rules! hdr { 95 + () => { 96 + "X-Gordian-User-Did" 97 + }; 98 + } 99 + 100 + const HEADER: &str = hdr!(); 101 + 102 + let did = parts 103 + .headers 104 + .get(HEADER) 105 + .ok_or(( 106 + StatusCode::BAD_REQUEST, 107 + concat!("'", hdr!(), "' header required").into(), 108 + ))? 109 + .to_str() 110 + .map_err(|_| { 111 + ( 112 + StatusCode::BAD_REQUEST, 113 + concat!("'", hdr!(), "' contains invalid ASCII").into(), 114 + ) 115 + })? 116 + .parse() 117 + .map_err(|_| { 118 + ( 119 + StatusCode::BAD_REQUEST, 120 + concat!("'", hdr!(), "' contains invalid DID").into(), 121 + ) 122 + })?; 123 + 124 + Ok(Self(did)) 125 + } 126 + } 127 + 128 + #[tracing::instrument(skip(knot, headers))] 129 + async fn hook_pre_receive( 130 + State(knot): State<Knot>, 131 + Path(RepositoryKey { owner, rkey }): Path<RepositoryKey>, 132 + ActorDid(actor): ActorDid, 133 + headers: HeaderMap, 134 + ) -> Result<impl IntoResponse, impl IntoResponse> { 135 + use data_encoding::BASE64_NOPAD as Encoding; 136 + 137 + let push_certificate = PushCertificate::try_from(&headers) 138 + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 139 + 140 + // @TODO Make acceptable slop configurable. 141 + if !push_certificate.is_good(Some(5)) { 142 + tracing::error!("push certificate rejected"); 143 + return Err(( 144 + StatusCode::FORBIDDEN, 145 + "Push certificate rejected".to_owned(), 146 + )); 147 + } 148 + 149 + let certificate_key_digest = push_certificate 150 + .signing_key_digest() 151 + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 152 + 153 + for public_key in knot 154 + .database() 155 + .public_keys_for_did(&actor) 156 + .await 157 + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))? 158 + { 159 + let mut key_parts = public_key.key.split_whitespace(); 160 + let key_data = match (key_parts.next(), key_parts.next()) { 161 + (Some(_), Some(key_data)) => key_data, 162 + _ => continue, 163 + }; 164 + 165 + let Ok(decoded) = Encoding.decode(key_data.as_bytes()) else { 166 + continue; 167 + }; 168 + 169 + let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &decoded); 170 + if digest.as_ref() == certificate_key_digest { 171 + let output = format!("Good signature for '{actor}'"); 172 + return Ok((StatusCode::OK, output)); 173 + } 174 + } 175 + 176 + let output = format!("Failed to find matching key for '{actor}'"); 177 + Err((StatusCode::FORBIDDEN, output)) 178 + } 179 + 180 + #[tracing::instrument(skip(knot, owner, rkey))] 181 + async fn hook_post_receive( 182 + State(knot): State<Knot>, 183 + Path(RepositoryKey { owner, rkey }): Path<RepositoryKey>, 184 + ActorDid(actor): ActorDid, 185 + body: String, 186 + ) -> Result<impl IntoResponse, XrpcError> { 187 + let repo_key = RepositoryKey { owner, rkey }; 188 + 189 + // Our hook refers to the repository using DID and rkey, but Tangled needs DID and name, so we 190 + // need to lookup the repository's name in the database. 191 + let (_, repo_name) = knot 192 + .database() 193 + .resolve_repository(&repo_key.owner, repo_key.rkey()) 194 + .await 195 + .map_err(errors::RepoNotFound)? 196 + .ok_or(errors::RepoNotFound(""))?; 197 + 198 + let repo = knot 199 + .open_repository(&repo_key) 200 + .await 201 + .map_err(errors::RepoNotFound)?; 202 + 203 + let default_ref = { 204 + let repository = repo.to_thread_local(); 205 + repository 206 + .head()? 207 + .referent_name() 208 + .ok_or(errors::HeadDetached)? 209 + .to_string() 210 + }; 211 + 212 + for line in body.lines() { 213 + let mut parts = line.split_whitespace(); 214 + let (old_sha, new_sha, refname) = 215 + match (parts.next(), parts.next(), parts.next(), parts.next()) { 216 + (Some(old_sha), Some(new_sha), Some(refname), None) => (old_sha, new_sha, refname), 217 + _ => panic!(), 218 + }; 219 + 220 + let old_sha = old_sha 221 + .parse() 222 + .expect("git should not output an invalid object ID"); 223 + let new_sha = new_sha 224 + .parse() 225 + .expect("git should not output an invalid object ID"); 226 + 227 + let commits = { 228 + let repo = repo.to_thread_local(); 229 + knot.pool() 230 + .spawn_fifo_async(move || repo.count_commits(&new_sha, &old_sha)) 231 + }; 232 + 233 + let languages = { 234 + let repo = repo.to_thread_local(); 235 + knot.pool() 236 + .spawn_fifo_async(move || repo.language_breakdown(&new_sha)) 237 + }; 238 + 239 + let (commits, languages) = tokio::join!(commits, languages); 240 + 241 + let meta = Meta { 242 + is_default_ref: refname == default_ref, 243 + commit_count: CommitCountBreakdown { 244 + by_email: commits 245 + .into_iter() 246 + .map(|(email, count)| CommitCount { email, count }) 247 + .collect(), 248 + }, 249 + lang_breakdown: LanguageBreakdown { 250 + inputs: languages 251 + .into_iter() 252 + .map(|(lang, size)| Language { lang, size }) 253 + .collect(), 254 + }, 255 + }; 256 + 257 + tracing::info!(?meta); 258 + 259 + let ref_update: RefUpdate = RefUpdate { 260 + r#ref: refname.to_string(), 261 + committer_did: actor.clone(), 262 + repo_did: repo_key.owner.clone(), 263 + repo_name: repo_name.clone(), 264 + old_sha: old_sha.into(), 265 + new_sha: old_sha.into(), 266 + meta, 267 + }; 268 + 269 + let ts = OffsetDateTime::now_utc(); 270 + let event = Event::RefUpdate(Arc::new(ref_update)); 271 + let id = knot 272 + .database() 273 + .insert_event( 274 + ts, 275 + &repo_key.owner, 276 + &repo_key.rkey, 277 + "sh.tangled.git.refUpdate", 278 + &event, 279 + ) 280 + .await 281 + .unwrap(); 282 + 283 + knot.send_event(id, ts, event).await; 284 + } 285 + 286 + Ok(StatusCode::NO_CONTENT) 287 + } 288 + 289 + #[tracing::instrument(skip(knot, repo_key))] 290 + async fn hook_post_update( 291 + State(knot): State<Knot>, 292 + Path(repo_key): Path<RepositoryKey>, 293 + ActorDid(actor): ActorDid, 294 + ) -> Result<impl IntoResponse, XrpcError> { 295 + let repo = knot 296 + .open_repository(&repo_key) 297 + .await 298 + .map_err(errors::RepoNotFound)? 299 + .to_thread_local(); 300 + 301 + let repository: TangledRepository = (knot.clone(), repo_key.clone(), repo).into(); 302 + 303 + // Schedule a maintenance run. 304 + knot.pool().spawn(move || { 305 + // We can do anything if this fails. 306 + let _ = run_maintenance(repo_key, repository).inspect_err(|error| tracing::error!(?error)); 307 + }); 308 + 309 + Ok(StatusCode::NO_CONTENT) 310 + } 311 + 312 + #[tracing::instrument(skip(repository))] 313 + fn run_maintenance( 314 + RepositoryKey { owner, rkey }: RepositoryKey, 315 + repository: TangledRepository, 316 + ) -> io::Result<()> { 317 + let output = repository 318 + .git() 319 + .args(["maintenance", "run"]) 320 + .stderr(Stdio::piped()) 321 + .spawn()? 322 + .wait_with_output()?; 323 + 324 + if !output.status.success() { 325 + let message = String::from_utf8_lossy(&output.stderr); 326 + tracing::error!(error = %message, "error running git maintenance"); 327 + } 328 + 329 + Ok(()) 330 + }
+123
crates/gordian-knot/src/public/events.rs
··· 1 + use std::time::Duration; 2 + 3 + use axum::{ 4 + extract::{ 5 + Query, State, WebSocketUpgrade, 6 + ws::{Message, WebSocket}, 7 + }, 8 + http::StatusCode, 9 + response::IntoResponse, 10 + }; 11 + use futures_util::{SinkExt as _, StreamExt as _, TryStreamExt as _}; 12 + use gordian_types::Tid; 13 + use serde::{Deserialize, Serialize}; 14 + use time::OffsetDateTime; 15 + use tokio::time::Instant; 16 + 17 + use crate::model::Knot; 18 + 19 + use super::xrpc::XrpcError; 20 + 21 + const KEEP_ALIVE: Duration = Duration::from_secs(45); 22 + 23 + #[derive(Serialize)] 24 + struct EventWrapper<'a, T> { 25 + nsid: &'a str, 26 + rkey: &'a str, 27 + event: &'a T, 28 + } 29 + 30 + #[derive(Deserialize)] 31 + pub struct EventsParameters { 32 + /// Nanoseconds from UNIX epoch. 33 + pub cursor: Option<i64>, 34 + } 35 + 36 + pub async fn handler( 37 + State(state): State<Knot>, 38 + Query(parameters): Query<EventsParameters>, 39 + ws: WebSocketUpgrade, 40 + ) -> Result<impl IntoResponse, XrpcError> { 41 + let cursor = parameters 42 + .cursor 43 + .and_then(|nanos| match nanos { 44 + 0 => None, 45 + _ => Some( 46 + OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).map_err(|error| { 47 + XrpcError::new(StatusCode::BAD_REQUEST, "InvalidCursor", error.to_string()) 48 + }), 49 + ), 50 + }) 51 + .transpose()?; 52 + 53 + Ok(ws.on_upgrade(move |socket| handle_socket(state, cursor, socket))) 54 + } 55 + 56 + async fn handle_socket(state: Knot, start_ts: Option<OffsetDateTime>, socket: WebSocket) { 57 + let mut keep_alive = tokio::time::interval(KEEP_ALIVE); 58 + keep_alive.tick().await; 59 + 60 + let mut events = state.subscribe_events(); 61 + let start = Instant::now(); 62 + 63 + let mut cursor = 0; 64 + let start_ts = start_ts.unwrap_or(OffsetDateTime::now_utc()); 65 + tracing::debug!(?start_ts, "new events listener"); 66 + 67 + let (mut sender, mut receiver) = socket.split(); 68 + 69 + let mut past_events = state.database().get_events(&start_ts); 70 + while let Some(Ok(event)) = past_events.next().await { 71 + cursor = event.id; 72 + let wrapper = EventWrapper { 73 + nsid: &event.collection, 74 + rkey: &Tid::from_datetime(event.ts, event.id.rem_euclid(1023).try_into().unwrap()) 75 + .to_string(), 76 + event: &event.record, 77 + }; 78 + 79 + let serialized = serde_json::to_string(&wrapper).unwrap(); 80 + if let Err(error) = sender.send(Message::text(serialized)).await { 81 + tracing::error!(?error, "failed to send event"); 82 + return; 83 + } 84 + } 85 + 86 + loop { 87 + let (id, ts, event) = tokio::select! { 88 + now = keep_alive.tick() => { 89 + let bytes = (now.duration_since(start)).as_secs().to_string().into(); 90 + if let Err(error) = sender.send(Message::Ping(bytes)).await { 91 + tracing::error!(?error, "failed to send ping"); 92 + break; 93 + } 94 + continue; 95 + } 96 + Ok(Some(message)) = receiver.try_next() => { 97 + tracing::trace!(?message); 98 + continue; 99 + } 100 + Ok(event) = events.recv() => event, 101 + else => break, 102 + }; 103 + 104 + if id < cursor { 105 + tracing::debug!(?id, "skipping event, client has already seen"); 106 + continue; 107 + } 108 + 109 + let wrapper = EventWrapper { 110 + nsid: event.collection(), 111 + rkey: &Tid::from_datetime(ts, id.rem_euclid(1023).try_into().unwrap()).to_string(), 112 + event: &event, 113 + }; 114 + 115 + let serialized = serde_json::to_string(&wrapper).unwrap(); 116 + if let Err(error) = sender.send(Message::text(serialized)).await { 117 + tracing::error!(?error, "failed to send event"); 118 + return; 119 + } 120 + 121 + cursor = id; 122 + } 123 + }
+113
crates/gordian-knot/src/public/git/authorization.rs
··· 1 + use axum::{ 2 + extract::{FromRef, FromRequestParts}, 3 + http::{header::AUTHORIZATION, request::Parts}, 4 + }; 5 + use gordian_auth::{ 6 + IntoVerificationKey, OpenSshKey, 7 + jwt::{Claims, Token, decode}, 8 + }; 9 + use gordian_identity::Resolver; 10 + use gordian_types::Nsid; 11 + use time::OffsetDateTime; 12 + 13 + use crate::{ 14 + model::Knot, 15 + nsid::SH_TANGLED_REPO_GITRECEIVEPACK, 16 + services::authorization::{ 17 + AuthorizationClaimsStore as _, Verification, VerificationError, extract_token, 18 + }, 19 + }; 20 + 21 + use super::Error; 22 + 23 + #[derive(Debug)] 24 + struct GitVerification; 25 + 26 + impl Verification for GitVerification { 27 + const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_GITRECEIVEPACK; 28 + } 29 + 30 + #[derive(Clone, Debug)] 31 + pub struct GitAuthorization(pub Claims); 32 + 33 + impl<S: Sync> FromRequestParts<S> for GitAuthorization 34 + where 35 + Knot: FromRef<S>, 36 + Resolver: FromRef<S>, 37 + { 38 + type Rejection = Error; 39 + 40 + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 41 + let knot = Knot::from_ref(state); 42 + let resolver = Resolver::from_ref(state); 43 + let now = OffsetDateTime::now_utc().unix_timestamp(); 44 + 45 + let credential = extract_token(parts, AUTHORIZATION, "bearer").ok_or( 46 + Error::unauthorized(&knot, "inter-service authorization required"), 47 + )?; 48 + 49 + let unverified_token = Token::decode_unverified(credential) 50 + .map_err(|_| Error::unauthorized(&knot, "inter-service authorization required"))?; 51 + 52 + // Before performing a relatively expensive DID look-up, ensure the token 53 + // claims are valid. 54 + let unverified_claims = unverified_token.claims; 55 + GitVerification::verify(&knot, now, knot.instance(), &unverified_claims) 56 + .await 57 + .map_err(|error| match error { 58 + // Git re-uses the token from the credential helper for each request in a single push. 59 + // 60 + // Returning 'Forbidden' here will make git abort. Instead, we return an Unauthorized 61 + // which will force git to get a new token from the credential helper. 62 + VerificationError::Reused => Error::unauthorized(&knot, "authorization re-used"), 63 + error => Error::forbidden(&knot, error.to_string()), 64 + })?; 65 + 66 + // Resolve the DID document for the claimed issuer, extract and parse 67 + // the verification methods into public keys. 68 + 69 + let (resolved_did, doc) = resolver 70 + .resolve(unverified_claims.iss.as_str()) 71 + .await 72 + .map_err(|error| Error::forbidden(&knot, error.to_string()))?; 73 + 74 + assert_eq!(unverified_claims.iss, resolved_did); 75 + 76 + let verification_keys = doc 77 + .verification_method 78 + .into_iter() 79 + .filter_map(|vm| vm.into_verification_key().ok()); 80 + 81 + // Try to decode and verify the JWT using any one of the verification keys 82 + // we have for the DID. 83 + for verification_key in verification_keys { 84 + if let Ok(token) = decode::<Claims>(credential, &verification_key) { 85 + // Store the JWT so it cannot be re-used within the claim period. 86 + knot.store_claims(token.claims.clone(), now).await?; 87 + return Ok(Self(token.claims)); 88 + } 89 + } 90 + 91 + // Read the 'sh.tangled.publicKey' records the knot has associated 92 + // with claimed issuer. 93 + let public_keys = knot 94 + .database() 95 + .public_keys_for_did(&unverified_claims.iss) 96 + .await 97 + .unwrap_or_default() 98 + .into_iter() 99 + .filter_map(|public_key| OpenSshKey(public_key.key).into_verification_key().ok()); 100 + 101 + // Try to decode and verify the JWT using any one of the public keys 102 + // we have for the DID. 103 + for verification_key in public_keys { 104 + if let Ok(token) = decode::<Claims>(credential, &verification_key) { 105 + // Store the JWT so it cannot be re-used within the claim period. 106 + knot.store_claims(token.claims.clone(), now).await?; 107 + return Ok(Self(token.claims)); 108 + } 109 + } 110 + 111 + Err(Error::forbidden(&knot, "No valid authorization found"))? 112 + } 113 + }
+255
crates/gordian-knot/src/public/xrpc.rs
··· 1 + use crate::model::{ 2 + Knot, errors, 3 + repository::{BlobError, RevspecError, TreeError}, 4 + }; 5 + use axum::{ 6 + Json, Router, 7 + extract::{FromRef, FromRequestParts}, 8 + http::StatusCode, 9 + response::IntoResponse, 10 + }; 11 + use gordian_identity::Resolver; 12 + use serde::de::DeserializeOwned; 13 + use std::borrow::Cow; 14 + 15 + pub mod sh_tangled; 16 + 17 + pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> 18 + where 19 + Knot: FromRef<S>, 20 + Resolver: FromRef<S>, 21 + { 22 + Router::<S>::new() 23 + .without_v07_checks() 24 + .merge(sh_tangled::owner()) 25 + .merge(sh_tangled::knot::version()) 26 + .merge(sh_tangled::repo::archive()) 27 + .merge(sh_tangled::repo::blob()) 28 + .merge(sh_tangled::repo::branch()) 29 + .merge(sh_tangled::repo::branches()) 30 + .merge(sh_tangled::repo::compare()) 31 + .merge(sh_tangled::repo::create()) 32 + .merge(sh_tangled::repo::delete()) 33 + .merge(sh_tangled::repo::diff()) 34 + .merge(sh_tangled::repo::get_default_branch()) 35 + .merge(sh_tangled::repo::merge_check()) 36 + .merge(sh_tangled::repo::languages()) 37 + .merge(sh_tangled::repo::log()) 38 + .merge(sh_tangled::repo::tags()) 39 + .merge(sh_tangled::repo::tree()) 40 + .merge(sh_tangled::repo::set_default_branch()) 41 + } 42 + 43 + pub type XrpcResult<T> = Result<XrpcResponse<T>, XrpcError>; 44 + 45 + pub struct XrpcResponse<T> { 46 + pub response: T, 47 + pub immutable: bool, 48 + } 49 + 50 + impl From<()> for XrpcResponse<()> { 51 + fn from(_: ()) -> Self { 52 + Self { 53 + response: (), 54 + immutable: false, 55 + } 56 + } 57 + } 58 + 59 + impl<T> From<Json<T>> for XrpcResponse<Json<T>> 60 + where 61 + Json<T>: IntoResponse, 62 + { 63 + fn from(value: Json<T>) -> Self { 64 + Self { 65 + response: value, 66 + immutable: false, 67 + } 68 + } 69 + } 70 + 71 + impl<T> IntoResponse for XrpcResponse<T> 72 + where 73 + T: IntoResponse, 74 + { 75 + fn into_response(self) -> axum::response::Response { 76 + use axum::http::header::{CACHE_CONTROL, HeaderValue}; 77 + 78 + let Self { 79 + response, 80 + immutable, 81 + } = self; 82 + 83 + let mut response = response.into_response(); 84 + if immutable { 85 + let headers = response.headers_mut(); 86 + headers.insert( 87 + CACHE_CONTROL, 88 + HeaderValue::from_static("public, immutable, s-maxage=604800"), 89 + ); 90 + } 91 + 92 + response 93 + } 94 + } 95 + 96 + #[derive(Debug, Default)] 97 + pub struct XrpcError { 98 + pub status: StatusCode, 99 + pub error: Cow<'static, str>, 100 + pub message: Cow<'static, str>, 101 + } 102 + 103 + impl XrpcError { 104 + pub fn new( 105 + status: impl Into<StatusCode>, 106 + error: &'static str, 107 + message: impl Into<Cow<'static, str>>, 108 + ) -> Self { 109 + Self { 110 + status: status.into(), 111 + error: Cow::Borrowed(error), 112 + message: message.into(), 113 + } 114 + } 115 + 116 + pub fn from_static( 117 + status: impl Into<StatusCode>, 118 + error: &'static str, 119 + message: &'static str, 120 + ) -> Self { 121 + Self { 122 + status: status.into(), 123 + error: Cow::Borrowed(error), 124 + message: Cow::Borrowed(message), 125 + } 126 + } 127 + } 128 + 129 + impl std::fmt::Display for XrpcError { 130 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 + write!(f, "{self:?}") 132 + } 133 + } 134 + 135 + impl IntoResponse for XrpcError { 136 + fn into_response(self) -> axum::response::Response { 137 + #[derive(serde::Serialize)] 138 + struct Body<'a> { 139 + error: &'a str, 140 + message: &'a str, 141 + } 142 + 143 + let body = Body { 144 + error: &self.error, 145 + message: &self.message, 146 + }; 147 + 148 + (self.status, Json(body)).into_response() 149 + } 150 + } 151 + 152 + impl<T: std::fmt::Display + std::fmt::Debug> From<(StatusCode, T)> for XrpcError { 153 + fn from((status, error): (StatusCode, T)) -> Self { 154 + Self { 155 + status, 156 + error: Cow::Owned(format!("{error:?}")), 157 + message: Cow::Owned(format!("{error}")), 158 + } 159 + } 160 + } 161 + 162 + macro_rules! ise { 163 + ($t:ty, $status:expr, $typ:literal) => { 164 + impl From<$t> for XrpcError { 165 + fn from(value: $t) -> Self { 166 + Self { 167 + status: $status, 168 + error: Cow::Borrowed($typ), 169 + message: Cow::Owned(format!("{value}").replace('"', "\'")), 170 + } 171 + } 172 + } 173 + }; 174 + ($t:ty, $status:expr) => { 175 + impl From<$t> for XrpcError { 176 + fn from(value: $t) -> Self { 177 + ($status, value).into() 178 + } 179 + } 180 + }; 181 + ($t:ty) => { 182 + ise!($t, StatusCode::INTERNAL_SERVER_ERROR); 183 + }; 184 + } 185 + 186 + ise!(std::io::Error); 187 + ise!(gix::diff::blob::platform::set_resource::Error); 188 + ise!(gix::diff::options::init::Error); 189 + ise!(gix::repository::diff_resource_cache::Error); 190 + ise!(gix::object::commit::Error); 191 + ise!(gix::objs::decode::Error); 192 + ise!(gix::object::find::existing::Error); 193 + ise!(gix::object::find::existing::with_conversion::Error); 194 + ise!(gix::object::tree::diff::for_each::Error); 195 + ise!(gix::reference::iter::Error); 196 + ise!(gix::reference::iter::init::Error); 197 + ise!(gix::reference::head_tree::Error); 198 + ise!(gix::reference::find::existing::Error, StatusCode::NOT_FOUND); 199 + ise!(gix::repository::commit_graph_if_enabled::Error); 200 + 201 + /// Wraps [`axum::extract::Query`] to customize the rejection type to [`XrpcError`]. 202 + /// 203 + pub struct XrpcQuery<T>(pub T); 204 + 205 + impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> { 206 + type Rejection = XrpcError; 207 + 208 + async fn from_request_parts( 209 + parts: &mut axum::http::request::Parts, 210 + state: &S, 211 + ) -> Result<Self, Self::Rejection> { 212 + use axum::extract::Query; 213 + 214 + let Query(params) = Query::from_request_parts(parts, state) 215 + .await 216 + .map_err(errors::InvalidRequest)?; 217 + 218 + Ok(Self(params)) 219 + } 220 + } 221 + 222 + impl From<RevspecError> for XrpcError { 223 + fn from(value: RevspecError) -> Self { 224 + Self { 225 + status: StatusCode::NOT_FOUND, 226 + error: "RefNotFound".into(), 227 + message: format!("{value}").into(), 228 + } 229 + } 230 + } 231 + 232 + impl From<BlobError> for XrpcError { 233 + fn from(value: BlobError) -> Self { 234 + let (status, error) = match value { 235 + BlobError::PathLookupError(_) => (StatusCode::NOT_FOUND, "FileNotFound"), 236 + BlobError::FileNotFound => (StatusCode::NOT_FOUND, "FileNotFound"), 237 + BlobError::NotABlob => (StatusCode::BAD_REQUEST, "InvalidRequest"), 238 + }; 239 + Self { 240 + status, 241 + error: error.into(), 242 + message: format!("{value}").into(), 243 + } 244 + } 245 + } 246 + 247 + impl From<TreeError> for XrpcError { 248 + fn from(value: TreeError) -> Self { 249 + Self { 250 + status: StatusCode::NOT_FOUND, 251 + error: "FileNotFound".into(), 252 + message: format!("{value}").into(), 253 + } 254 + } 255 + }
+39
crates/gordian-knot/src/public/xrpc/sh_tangled.rs
··· 1 + use crate::model::Knot; 2 + 3 + pub mod knot; 4 + pub mod repo; 5 + 6 + /// Get the owner of a service. 7 + /// 8 + /// <https://tangled.org/tangled.org/core/blob/master/lexicons/owner.json> 9 + /// 10 + pub fn owner<S>() -> axum::Router<S> 11 + where 12 + S: Clone + Send + Sync + 'static, 13 + Knot: axum::extract::FromRef<S>, 14 + { 15 + use impl_owner::{LXM, owner_query}; 16 + axum::Router::new().route(LXM, axum::routing::get(owner_query)) 17 + } 18 + 19 + mod impl_owner { 20 + use axum::{ 21 + Json, 22 + extract::{FromRef, State}, 23 + response::{IntoResponse, Response}, 24 + }; 25 + 26 + use crate::{lexicon::sh_tangled::owner::Output, model::Knot}; 27 + 28 + pub const LXM: &str = "/sh.tangled.owner"; 29 + 30 + pub async fn owner_query<S>(State(knot): State<Knot>) -> Response 31 + where 32 + Knot: FromRef<S>, 33 + { 34 + Json(Output { 35 + owner: knot.owner(), 36 + }) 37 + .into_response() 38 + } 39 + }
+27
crates/gordian-knot/src/public/xrpc/sh_tangled/knot.rs
··· 1 + use crate::model::Knot; 2 + 3 + /// Get the version of a knot. 4 + /// 5 + /// <https://tangled.org/tangled.org/core/blob/master/lexicons/knot/version.json> 6 + /// 7 + pub fn version<S>() -> axum::Router<S> 8 + where 9 + S: Clone + Send + Sync + 'static, 10 + Knot: axum::extract::FromRef<S>, 11 + { 12 + use impl_version::{LXM, handle}; 13 + axum::Router::new().route(LXM, axum::routing::get(handle)) 14 + } 15 + 16 + mod impl_version { 17 + use axum::Json; 18 + use gordian_lexicon::sh_tangled::knot::version::Output; 19 + 20 + pub const LXM: &str = "/sh.tangled.knot.version"; 21 + 22 + pub async fn handle() -> Json<Output> { 23 + Json(Output { 24 + version: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).into(), 25 + }) 26 + } 27 + }
+60
crates/gordian-knot/src/public/xrpc/sh_tangled/repo.rs
··· 1 + use gordian_identity::Resolver; 2 + 3 + use crate::model::Knot; 4 + 5 + mod impl_archive; 6 + mod impl_blob; 7 + mod impl_branch; 8 + mod impl_branches; 9 + mod impl_compare; 10 + mod impl_create; 11 + mod impl_delete; 12 + mod impl_diff; 13 + mod impl_get_default_branch; 14 + mod impl_languages; 15 + mod impl_log; 16 + mod impl_merge_check; 17 + mod impl_set_default_branch; 18 + mod impl_tags; 19 + mod impl_tree; 20 + 21 + macro_rules! impl_xrpc { 22 + (QUERY, $name:ident, $module:ident) => { 23 + pub fn $name<S>() -> axum::Router<S> 24 + where 25 + S: Clone + Send + Sync + 'static, 26 + Knot: axum::extract::FromRef<S>, 27 + { 28 + use $module::{LXM, handle}; 29 + axum::Router::new().route(LXM, axum::routing::get(handle)) 30 + } 31 + }; 32 + (PROCEDURE, $name:ident, $module:ident) => { 33 + pub fn $name<S>() -> axum::Router<S> 34 + where 35 + S: Clone + Send + Sync + 'static, 36 + Knot: axum::extract::FromRef<S>, 37 + Resolver: axum::extract::FromRef<S>, 38 + { 39 + use $module::{LXM, handle}; 40 + axum::Router::new().route(LXM, axum::routing::post(handle)) 41 + } 42 + }; 43 + } 44 + 45 + impl_xrpc!(QUERY, archive, impl_archive); 46 + impl_xrpc!(QUERY, blob, impl_blob); 47 + impl_xrpc!(QUERY, branch, impl_branch); 48 + impl_xrpc!(QUERY, branches, impl_branches); 49 + impl_xrpc!(QUERY, compare, impl_compare); 50 + impl_xrpc!(QUERY, diff, impl_diff); 51 + impl_xrpc!(QUERY, get_default_branch, impl_get_default_branch); 52 + impl_xrpc!(QUERY, languages, impl_languages); 53 + impl_xrpc!(QUERY, log, impl_log); 54 + impl_xrpc!(QUERY, tags, impl_tags); 55 + impl_xrpc!(QUERY, tree, impl_tree); 56 + 57 + impl_xrpc!(PROCEDURE, create, impl_create); 58 + impl_xrpc!(PROCEDURE, delete, impl_delete); 59 + impl_xrpc!(PROCEDURE, merge_check, impl_merge_check); 60 + impl_xrpc!(PROCEDURE, set_default_branch, impl_set_default_branch);
+135
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_archive.rs
··· 1 + use crate::model::Knot; 2 + use crate::model::errors; 3 + use crate::model::repository::ResolvedRevspec; 4 + use crate::model::repository::TangledRepository; 5 + use crate::public::xrpc::XrpcError; 6 + use crate::public::xrpc::XrpcQuery; 7 + use crate::types::repository_path::RepositoryPath; 8 + use axum::extract::State; 9 + use axum::http::HeaderMap; 10 + use axum::http::HeaderValue; 11 + use axum::http::StatusCode; 12 + use axum::response::IntoResponse; 13 + use axum_extra::body::AsyncReadBody; 14 + use git_service::util::SetOptionArg as _; 15 + use gordian_lexicon::sh_tangled::repo::archive::Format; 16 + use gordian_lexicon::sh_tangled::repo::archive::Input; 17 + use std::process::Stdio; 18 + use std::time::Duration; 19 + 20 + pub const LXM: &str = "/sh.tangled.repo.archive"; 21 + 22 + const SPAWN_WAIT: Duration = Duration::from_millis(100); 23 + 24 + #[tracing::instrument(target = "sh_tangled::repo::archive", skip(knot, repository), err)] 25 + pub async fn handle( 26 + State(knot): State<Knot>, 27 + XrpcQuery(Input { 28 + repo, 29 + rev, 30 + format, 31 + prefix, 32 + }): XrpcQuery<Input>, 33 + repository: TangledRepository, 34 + ) -> Result<impl IntoResponse, XrpcError> { 35 + use crate::model::repository::ResolveRevspec as _; 36 + use axum::http::header; 37 + 38 + let repo_path: RepositoryPath = repo 39 + .parse() 40 + .expect("Repository extractor should have validated repo parameter"); 41 + 42 + let (mut child, immutable, link) = { 43 + let ResolvedRevspec { commit, immutable } = 44 + repository.resolve_revspec(&Some(rev.as_str()))?; 45 + 46 + let rev = commit.id.to_string(); 47 + let immutable_link = immutable_link( 48 + &format!("https://{}/xrpc{LXM}", knot.instance_ident()), 49 + &repo, 50 + &rev, 51 + prefix.as_deref(), 52 + format, 53 + ); 54 + 55 + let mut command: tokio::process::Command = repository.git().into(); 56 + let child = command 57 + .arg("archive") 58 + .arg(format!("--format={format}")) 59 + .option_arg(prefix.map(|prefix| format!("--prefix={prefix}/"))) 60 + .arg(&rev) 61 + .stdout(Stdio::piped()) 62 + .stderr(Stdio::piped()) 63 + .spawn()?; 64 + 65 + (child, immutable, immutable_link) 66 + }; 67 + 68 + // Allow some time for the spawned command to run. 69 + tokio::time::sleep(SPAWN_WAIT).await; 70 + if let Ok(Some(exit_status)) = child.try_wait() 71 + && !exit_status.success() 72 + { 73 + tracing::error!(?exit_status, "failed to spawn git-archive"); 74 + let output = child.wait_with_output().await.map_err(errors::Internal)?; 75 + let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); 76 + return Err(XrpcError { 77 + status: StatusCode::INTERNAL_SERVER_ERROR, 78 + error: "ArchiveError".into(), 79 + message: message.into(), 80 + }); 81 + } 82 + 83 + let stdout = child 84 + .stdout 85 + .take() 86 + .expect("Child process stdout handle should be set"); 87 + 88 + let mut headers = HeaderMap::new(); 89 + if immutable { 90 + headers.insert( 91 + header::CACHE_CONTROL, 92 + HeaderValue::from_static("public, immutable, s-maxage=604800"), 93 + ); 94 + } 95 + 96 + let repo_name = repo_path.name; 97 + let safe_ref = rev.replace('/', "-"); 98 + headers.insert( 99 + header::CONTENT_DISPOSITION, 100 + HeaderValue::from_str(&format!( 101 + "attachment; filename=\"{repo_name}-{safe_ref}.{format}\"" 102 + )) 103 + .map_err(errors::Internal)?, 104 + ); 105 + headers.insert( 106 + header::CONTENT_TYPE, 107 + HeaderValue::from_static(format.as_content_type()), 108 + ); 109 + headers.insert( 110 + header::LINK, 111 + HeaderValue::from_str(&format!("<{link}>; rel=\"immutable\"")).map_err(errors::Internal)?, 112 + ); 113 + 114 + Ok((headers, AsyncReadBody::new(stdout)).into_response()) 115 + } 116 + 117 + fn immutable_link( 118 + base: &str, 119 + repo: &str, 120 + revision: &str, 121 + prefix: Option<&str>, 122 + format: Format, 123 + ) -> url::Url { 124 + let mut url = url::Url::parse(&base).expect("Base URL should be valid"); 125 + { 126 + let mut query = url.query_pairs_mut(); 127 + query.append_pair("repo", &repo); 128 + query.append_pair("format", format.as_str()); 129 + query.append_pair("ref", &revision); 130 + if let Some(prefix) = &prefix { 131 + query.append_pair("prefix", prefix); 132 + } 133 + } 134 + url 135 + }
+132
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_blob.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use axum::{ 4 + Json, 5 + extract::State, 6 + http::{HeaderMap, HeaderValue, StatusCode}, 7 + response::{IntoResponse, Response}, 8 + }; 9 + use data_encoding::BASE64; 10 + use gordian_lexicon::sh_tangled::repo::blob::{Encoding, Input, Output, Submodule}; 11 + use mimetype_detector::MimeType; 12 + use reqwest::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG}; 13 + use tokio_rayon::AsyncThreadPool as _; 14 + 15 + use crate::{ 16 + extractors::IfNoneMatch, 17 + model::{ 18 + Knot, 19 + repository::{ResolveRevspec as _, ResolvedRevspec, TangledRepository}, 20 + }, 21 + public::xrpc::{XrpcError, XrpcQuery}, 22 + }; 23 + 24 + pub const LXM: &str = "/sh.tangled.repo.blob"; 25 + 26 + #[tracing::instrument( 27 + target = "sh_tangled::repo::blob", 28 + skip(knot, if_none_match, repository), 29 + err 30 + )] 31 + pub async fn handle( 32 + State(knot): State<Knot>, 33 + if_none_match: Option<IfNoneMatch>, 34 + XrpcQuery(Input { 35 + repo, 36 + rev, 37 + path, 38 + raw, 39 + }): XrpcQuery<Input>, 40 + repository: TangledRepository, 41 + ) -> Result<Response, XrpcError> { 42 + knot.pool() 43 + .spawn_async(move || { 44 + let ResolvedRevspec { commit, immutable } = 45 + repository.resolve_revspec(&Some(rev.as_str()))?; 46 + 47 + // Use the tree object ID as an ETag. 48 + // 49 + // 1. If the blob content has changed, the blob object ID will be different, and 50 + // therefore the tree object ID will also be different. 51 + // 52 + // 2. Using the tree object ID avoids searching the tree for the blob path. 53 + 54 + let tree = repository.get_tree(&commit)?; 55 + if if_none_match.is_some_and(|etags| etags.contains(&tree.id.to_string())) { 56 + return Ok(StatusCode::NOT_MODIFIED.into_response()); 57 + } 58 + 59 + let mut response = match repository.submodule(&path) { 60 + Some(submodule) => Json(blob_submodule(rev, path, submodule)).into_response(), 61 + None => { 62 + let buffer = repository.get_blob(&tree, &path)?; 63 + let mime_type = mimetype_detector::detect(&buffer); 64 + match raw { 65 + true => blob_raw(mime_type, buffer), 66 + false => Json(blob_json(rev, path, mime_type, buffer)).into_response(), 67 + } 68 + } 69 + }; 70 + 71 + let headers = response.headers_mut(); 72 + if immutable { 73 + headers.insert( 74 + CACHE_CONTROL, 75 + HeaderValue::from_static("public, immutable, s-maxage=604800"), 76 + ); 77 + } 78 + 79 + headers.insert( 80 + ETAG, 81 + HeaderValue::from_str(&format!("\"{}\"", tree.id)) 82 + .expect("Hex-string should be a valid header value"), 83 + ); 84 + 85 + Ok(response) 86 + }) 87 + .await 88 + } 89 + 90 + fn blob_submodule(rev: String, path: PathBuf, submodule: Submodule) -> Output { 91 + Output { 92 + rev, 93 + path, 94 + content: None, 95 + encoding: None, 96 + size: None, 97 + is_binary: None, 98 + mime_type: None, 99 + submodule: Some(submodule), 100 + last_commit: None, 101 + } 102 + } 103 + 104 + fn blob_json(rev: String, path: PathBuf, mime_type: &MimeType, buffer: Vec<u8>) -> Output { 105 + let size = buffer.len(); 106 + let (content, encoding, is_binary) = match String::from_utf8(buffer) { 107 + Ok(content) => (content, Encoding::Utf8, false), 108 + Err(error) => (BASE64.encode(error.as_bytes()), Encoding::Base64, true), 109 + }; 110 + 111 + Output { 112 + rev, 113 + path, 114 + encoding: Some(encoding), 115 + size: Some(size), 116 + content: Some(content), 117 + mime_type: Some(mime_type.mime().into()), 118 + is_binary: Some(is_binary), 119 + submodule: None, 120 + last_commit: None, 121 + } 122 + } 123 + 124 + fn blob_raw(mime_type: &MimeType, buffer: Vec<u8>) -> Response { 125 + let mut headers = HeaderMap::new(); 126 + headers.insert( 127 + CONTENT_TYPE, 128 + HeaderValue::from_str(mime_type.mime()).expect("MIME type should be a valid header value"), 129 + ); 130 + 131 + (headers, buffer).into_response() 132 + }
+21
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branch.rs
··· 1 + use axum::{Json, extract::State}; 2 + use gordian_lexicon::sh_tangled::repo::branch::{Input, Output}; 3 + use tokio_rayon::AsyncThreadPool as _; 4 + 5 + use crate::{ 6 + model::{Knot, repository::TangledRepository}, 7 + public::xrpc::{XrpcQuery, XrpcResult}, 8 + }; 9 + 10 + pub const LXM: &str = "/sh.tangled.repo.branch"; 11 + 12 + #[tracing::instrument(target = "sh_tangled::repo::branch", skip(knot, repository), err)] 13 + pub async fn handle( 14 + State(knot): State<Knot>, 15 + XrpcQuery(params): XrpcQuery<Input>, 16 + repository: TangledRepository, 17 + ) -> XrpcResult<Json<Output>> { 18 + knot.pool() 19 + .spawn_async(move || repository.branch(params)) 20 + .await 21 + }
+85
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_create.rs
··· 1 + use axum::{Json, extract::State, http::StatusCode}; 2 + use gordian_lexicon::{ 3 + com::atproto::repo::list_records::Record, 4 + sh_tangled::repo::{Repo, create::Input}, 5 + }; 6 + use gordian_types::Nsid; 7 + 8 + use crate::{ 9 + model::{Knot, errors}, 10 + nsid::SH_TANGLED_REPO_CREATE, 11 + public::xrpc::{XrpcError, XrpcResult}, 12 + services::{ 13 + atrepo, 14 + authorization::{Authorization, Verification}, 15 + }, 16 + types::RecordKey, 17 + }; 18 + 19 + pub const LXM: &str = "/sh.tangled.repo.create"; 20 + 21 + #[derive(Debug, Default)] 22 + pub struct CreateVerification; 23 + 24 + impl Verification for CreateVerification { 25 + const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_CREATE; 26 + } 27 + 28 + #[tracing::instrument(target = "sh_tangled::repo::create", skip(knot, authorization), err)] 29 + pub async fn handle( 30 + State(knot): State<Knot>, 31 + authorization: Authorization<CreateVerification>, 32 + Json(params): Json<Input>, 33 + ) -> XrpcResult<()> { 34 + use crate::services::rbac::{Action, Policy, PolicyResult::*, RepositoryCreatePolicy}; 35 + 36 + let claims = authorization.claims(); 37 + let policy = RepositoryCreatePolicy; 38 + let can_create = policy 39 + .evaluate_access( 40 + &claims.iss.as_ref(), 41 + &Action::RepositoryCreate, 42 + &knot, 43 + &knot, 44 + ) 45 + .await; 46 + 47 + if !matches!(can_create, Granted) { 48 + return Err(errors::Forbidden(format!( 49 + "'{}' does not have permission to create repositories on this knot", 50 + claims.iss 51 + )))?; 52 + } 53 + 54 + // Fetch repository record from pds. 55 + let response = atrepo::fetch_record_bytes( 56 + knot.resolver(), 57 + knot.http(), 58 + &claims.iss, 59 + "sh.tangled.repo", 60 + &params.rkey, 61 + ) 62 + .await 63 + .inspect_err(|error| tracing::error!(?error, did = %claims.iss, rkey = %params.rkey, "unable to fetch record")) 64 + .map_err(errors::RepoError)?; 65 + 66 + let record = serde_json::from_slice::<Record>(&response) 67 + .inspect_err(|error| tracing::error!(?error)) 68 + .map_err(errors::RepoError)?; 69 + 70 + let repo: Repo = serde_json::from_str(record.value.get()).map_err(|error| { 71 + XrpcError::new( 72 + StatusCode::INTERNAL_SERVER_ERROR, 73 + "LexiconError", 74 + error.to_string(), 75 + ) 76 + })?; 77 + 78 + let rec = RecordKey::try_from(&record).map_err(errors::InvalidRequest)?; 79 + knot.create_repo(&rec, &repo) 80 + .await 81 + .inspect_err(|error| tracing::error!(?error, "failed to create repository")) 82 + .map_err(errors::Internal)?; 83 + 84 + Ok(().into()) 85 + }
+68
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_delete.rs
··· 1 + use axum::{Json, extract::State}; 2 + use gordian_lexicon::sh_tangled::repo::delete::Input; 3 + use gordian_types::{Nsid, Tid}; 4 + 5 + use crate::{ 6 + model::{Knot, errors}, 7 + nsid::SH_TANGLED_REPO_DELETE, 8 + public::xrpc::XrpcResult, 9 + services::authorization::{Authorization, Verification}, 10 + types::RecordKey, 11 + }; 12 + 13 + pub const LXM: &str = "/sh.tangled.repo.delete"; 14 + 15 + #[derive(Debug, Default)] 16 + pub struct DeleteVerification; 17 + 18 + impl Verification for DeleteVerification { 19 + const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_DELETE; 20 + } 21 + 22 + #[tracing::instrument(target = "sh_tangled::repo::delete", skip(knot, authorization), err)] 23 + pub async fn handle( 24 + State(knot): State<Knot>, 25 + authorization: Authorization<DeleteVerification>, 26 + Json(params): Json<Input>, 27 + ) -> XrpcResult<()> { 28 + use crate::services::rbac::{ 29 + Action, Policy, PolicyResult::*, RepositoryDeletePolicy, RepositoryRef, 30 + }; 31 + 32 + let claims = authorization.claims(); 33 + let policy = RepositoryDeletePolicy; 34 + let repository = RepositoryRef::new(&params.did, &params.rkey); 35 + let can_delete = policy 36 + .evaluate_access( 37 + &claims.iss.as_ref(), 38 + &Action::RepositoryDelete, 39 + &repository, 40 + &knot, 41 + ) 42 + .await; 43 + 44 + if !matches!(can_delete, Granted) { 45 + return Err(errors::Forbidden(format!( 46 + "'{}' does not have permission to delete repository '{}/{}'", 47 + claims.iss, params.did, params.rkey 48 + )))?; 49 + } 50 + 51 + // @NB Alternative strategy: 52 + // 53 + // 1. Check delete permissions. 54 + // 2. Immediately return OK. 55 + // 3. Wait for delete event from Jetstream/Firehose. 56 + 57 + let rec = RecordKey { 58 + did: &params.did, 59 + collection: "sh.tangled.repo", 60 + rkey: &params.rkey, 61 + rev: &Tid::MAX.to_string(), 62 + cid: "", 63 + }; 64 + 65 + knot.delete_repo(&rec).await.map_err(errors::Internal)?; 66 + 67 + Ok(().into()) 68 + }
+25
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_get_default_branch.rs
··· 1 + use axum::{Json, extract::State}; 2 + use gordian_lexicon::sh_tangled::repo::get_default_branch::{Input, Output}; 3 + use tokio_rayon::AsyncThreadPool as _; 4 + 5 + use crate::{ 6 + model::{Knot, repository::TangledRepository}, 7 + public::xrpc::{XrpcQuery, XrpcResult}, 8 + }; 9 + 10 + pub const LXM: &str = "/sh.tangled.repo.getDefaultBranch"; 11 + 12 + #[tracing::instrument( 13 + target = "sh_tangled::repo::getDefaultBranch", 14 + skip(knot, repository), 15 + err 16 + )] 17 + pub async fn handle( 18 + State(knot): State<Knot>, 19 + XrpcQuery(params): XrpcQuery<Input>, 20 + repository: TangledRepository, 21 + ) -> XrpcResult<Json<Output>> { 22 + knot.pool() 23 + .spawn_async(move || repository.get_default_branch(params)) 24 + .await 25 + }
+21
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_languages.rs
··· 1 + use axum::{Json, extract::State}; 2 + use tokio_rayon::AsyncThreadPool as _; 3 + 4 + use crate::{ 5 + lexicon::sh_tangled::repo::languages::{Input, Output}, 6 + model::{Knot, repository::TangledRepository}, 7 + public::xrpc::{XrpcQuery, XrpcResult}, 8 + }; 9 + 10 + pub const LXM: &str = "/sh.tangled.repo.languages"; 11 + 12 + #[tracing::instrument(target = "sh_tangled::repo::languages", skip(knot, repository), err)] 13 + pub async fn handle( 14 + State(knot): State<Knot>, 15 + XrpcQuery(params): XrpcQuery<Input>, 16 + repository: TangledRepository, 17 + ) -> XrpcResult<Json<Output>> { 18 + knot.pool() 19 + .spawn_async(move || repository.languages(params)) 20 + .await 21 + }
+44
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_merge_check.rs
··· 1 + use axum::{Json, extract::State}; 2 + use tokio_rayon::AsyncThreadPool as _; 3 + 4 + use crate::{ 5 + lexicon::sh_tangled::repo::merge_check::{Input, Output}, 6 + model::{Knot, errors, repository::TangledRepository}, 7 + public::xrpc::XrpcResult, 8 + types::repository_path::RepositoryPath, 9 + }; 10 + 11 + pub const LXM: &str = "/sh.tangled.repo.mergeCheck"; 12 + 13 + #[tracing::instrument(target = "sh_tangled::repo::merge_check", skip(knot, patch), err)] 14 + pub async fn handle( 15 + State(knot): State<Knot>, 16 + Json(Input { 17 + did, 18 + name, 19 + patch, 20 + branch, 21 + }): Json<Input>, 22 + ) -> XrpcResult<Json<Output>> { 23 + let repo_path = RepositoryPath { 24 + owner: did.into_boxed().into(), 25 + name: name.into_boxed_str(), 26 + }; 27 + 28 + let repo_key = knot 29 + .resolve_repo_key(&repo_path) 30 + .await 31 + .map_err(errors::RepoNotFound)?; 32 + 33 + let repo = knot 34 + .open_repository(&repo_key) 35 + .await 36 + .map_err(errors::RepoNotFound)? 37 + .to_thread_local(); 38 + 39 + let repository: TangledRepository = (knot.clone(), repo_key, repo).into(); 40 + 41 + knot.pool() 42 + .spawn_async(move || repository.merge_check(patch, &branch)) 43 + .await 44 + }
+105
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_set_default_branch.rs
··· 1 + use axum::{Json, extract::State}; 2 + use gix::{ 3 + lock::acquire::Fail, 4 + refs::{ 5 + FullName, Target, 6 + transaction::{Change, LogChange, PreviousValue, RefEdit}, 7 + }, 8 + }; 9 + use gordian_types::Nsid; 10 + 11 + use crate::{ 12 + lexicon::sh_tangled::repo::set_default_branch::Input, 13 + model::{Knot, errors}, 14 + nsid::SH_TANGLED_REPO_SETDEFAULTBRANCH, 15 + public::xrpc::XrpcError, 16 + services::{ 17 + authorization::{Authorization, Verification}, 18 + rbac::{Action, Policy, PolicyResult::Granted, RepositoryEditPolicy, RepositoryRef}, 19 + }, 20 + types::repository_key::RepositoryKey, 21 + }; 22 + 23 + pub const LXM: &str = "/sh.tangled.repo.setDefaultBranch"; 24 + 25 + #[derive(Debug)] 26 + pub struct SetDefaultBranchVerification; 27 + 28 + impl Verification for SetDefaultBranchVerification { 29 + const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_SETDEFAULTBRANCH; 30 + } 31 + 32 + #[tracing::instrument(target = "sh_tangled::repo::setDefaultBranch", skip(knot), err)] 33 + pub async fn handle( 34 + State(knot): State<Knot>, 35 + authorization: Authorization<SetDefaultBranchVerification>, 36 + Json(Input { 37 + repo, 38 + default_branch, 39 + }): Json<Input>, 40 + ) -> Result<(), XrpcError> { 41 + if repo.collection != "sh.tangled.repo" { 42 + return Err(errors::InvalidRequest( 43 + "Wrong collection in repo URI, expected 'sh.tangled.repo'", 44 + ))?; 45 + } 46 + 47 + let repo_key = RepositoryKey::new(repo.authority, repo.rkey).map_err(errors::InvalidRequest)?; 48 + 49 + let claims = authorization.claims(); 50 + let policy = RepositoryEditPolicy; 51 + let can_create = policy 52 + .evaluate_access( 53 + &claims.iss.as_ref(), 54 + &Action::RepositoryEdit, 55 + &RepositoryRef::from(&repo_key), 56 + &knot, 57 + ) 58 + .await; 59 + 60 + if !matches!(can_create, Granted) { 61 + return Err(errors::Forbidden(format!( 62 + "'{}' does not have permission to modify repositories on this knot", 63 + claims.iss 64 + )))?; 65 + } 66 + 67 + let repository = knot 68 + .open_repository(&repo_key) 69 + .await 70 + .map_err(errors::RepoNotFound)? 71 + .to_thread_local(); 72 + 73 + let target_name: FullName = format!("refs/heads/{default_branch}") 74 + .try_into() 75 + .map_err(errors::InvalidRequest)?; 76 + 77 + let ref_change = RefEdit { 78 + change: Change::Update { 79 + log: LogChange::default(), 80 + expected: PreviousValue::Any, 81 + new: Target::Symbolic(target_name), 82 + }, 83 + name: "HEAD".try_into().expect("HEAD is a valid reference"), 84 + deref: false, 85 + }; 86 + 87 + let ref_log = repository 88 + .refs 89 + .transaction() 90 + .prepare([ref_change], Fail::Immediately, Fail::Immediately) 91 + .map_err(errors::Internal)? 92 + .commit(None) 93 + .map_err(errors::Internal)?; 94 + 95 + let Some(change) = ref_log.first() else { 96 + return Err(errors::Internal("Not ref changes applied"))?; 97 + }; 98 + 99 + let from = change.change.previous_value(); 100 + let to = change.change.new_value(); 101 + 102 + tracing::info!(?from, ?to, "updated HEAD"); 103 + 104 + Ok(()) 105 + }
+22
crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tree.rs
··· 1 + use axum::{Json, extract::State}; 2 + use tokio_rayon::AsyncThreadPool as _; 3 + 4 + use crate::{ 5 + lexicon::sh_tangled::repo::tree::{Input, Output}, 6 + model::{Knot, repository::TangledRepository}, 7 + public::xrpc::{XrpcQuery, XrpcResult}, 8 + }; 9 + 10 + pub const LXM: &str = "/sh.tangled.repo.tree"; 11 + 12 + #[tracing::instrument(target = "sh_tangled::repo::tree", skip(knot, repository), err)] 13 + pub async fn handle( 14 + State(knot): State<Knot>, 15 + XrpcQuery(params): XrpcQuery<Input>, 16 + repository: TangledRepository, 17 + ) -> XrpcResult<Json<Output>> { 18 + let cloned_knot = knot.clone(); 19 + knot.pool() 20 + .spawn_async(move || repository.tree(params, cloned_knot.readmes())) 21 + .await 22 + }
+118
crates/gordian-knot/src/services/atrepo.rs
··· 1 + use crate::lexicon::com::atproto::repo::list_records; 2 + use bytes::Bytes; 3 + use gix::bstr::ByteSlice as _; 4 + use gordian_identity::HttpClient; 5 + use gordian_types::Did; 6 + 7 + #[derive(Debug, thiserror::Error)] 8 + pub enum Error<E> { 9 + #[error(transparent)] 10 + Reqwest(#[from] reqwest::Error), 11 + #[error(transparent)] 12 + Resolve(#[from] gordian_identity::ResolveError), 13 + #[error("DID document fails to declare PDS service endpoint")] 14 + MissingPDS, 15 + #[error(transparent)] 16 + InvalidAtUri(#[from] gordian_types::aturi::Error), 17 + #[error(transparent)] 18 + Serde(#[from] serde_json::Error), 19 + #[error("Error in callback")] 20 + Callback(E), 21 + } 22 + 23 + pub async fn fetch_collection<F, E>( 24 + resolver: &gordian_identity::Resolver, 25 + http: &reqwest::Client, 26 + did: &Did, 27 + collection: &str, 28 + mut callback: F, 29 + ) -> Result<(), Error<E>> 30 + where 31 + F: AsyncFnMut(&[list_records::Record]) -> Result<(), E> + Send + 'static, 32 + { 33 + use url::Url; 34 + 35 + fn list_records_url(mut pds: Url, collection: &str, repo: &Did, cursor: Option<&str>) -> Url { 36 + pds.set_path("/xrpc/com.atproto.repo.listRecords"); 37 + 38 + let mut query = pds.query_pairs_mut(); 39 + query.append_pair("repo", repo.as_str()); 40 + query.append_pair("collection", collection); 41 + if let Some(cursor) = cursor { 42 + query.append_pair("cursor", cursor); 43 + } 44 + drop(query); 45 + 46 + pds 47 + } 48 + 49 + let (_, doc) = resolver.resolve(did.as_str()).await?; 50 + let pds = &doc.atproto_pds().ok_or(Error::MissingPDS)?.service_endpoint; 51 + 52 + let mut complete = false; 53 + let mut cursor: Option<String> = None; 54 + while !complete { 55 + let response = http 56 + .get(list_records_url( 57 + pds.clone(), 58 + collection, 59 + did, 60 + cursor.as_deref(), 61 + )) 62 + .send() 63 + .await? 64 + .error_for_status()? 65 + .bytes() 66 + .await?; 67 + 68 + let parsed: list_records::Output = serde_json::from_slice(response.as_bytes())?; 69 + if let Some(new_cursor) = parsed.cursor { 70 + cursor.replace(new_cursor.to_owned()); 71 + } 72 + 73 + complete = parsed 74 + .records 75 + .last() 76 + .is_none_or(|last| Some(last.uri.rkey.as_str()) == cursor.as_deref()); 77 + 78 + callback(&parsed.records).await.map_err(Error::Callback)?; 79 + } 80 + 81 + Ok(()) 82 + } 83 + 84 + pub async fn fetch_record_bytes( 85 + resolver: &gordian_identity::Resolver, 86 + http: &HttpClient, 87 + did: &Did, 88 + collection: &str, 89 + rkey: &str, 90 + ) -> anyhow::Result<Bytes> { 91 + use url::Url; 92 + 93 + fn get_record_url(mut pds: Url, repo: &Did, collection: &str, rkey: &str) -> Url { 94 + pds.set_path("/xrpc/com.atproto.repo.getRecord"); 95 + let mut query = pds.query_pairs_mut(); 96 + query.append_pair("repo", repo.as_str()); 97 + query.append_pair("collection", collection); 98 + query.append_pair("rkey", rkey); 99 + drop(query); 100 + pds 101 + } 102 + 103 + let (_, doc) = resolver.resolve(did.as_str()).await?; 104 + let pds = &doc 105 + .atproto_pds() 106 + .ok_or(anyhow::anyhow!("DID document does not declare a pds"))? 107 + .service_endpoint; 108 + 109 + let response = http 110 + .get(get_record_url(pds.clone(), did, collection, rkey)) 111 + .send() 112 + .await? 113 + .error_for_status()? 114 + .bytes() 115 + .await?; 116 + 117 + Ok(response) 118 + }
+234
crates/gordian-knot/src/services/authorization.rs
··· 1 + use core::fmt; 2 + 3 + use axum::{ 4 + extract::{FromRef, FromRequestParts}, 5 + http::{ 6 + header::{AUTHORIZATION, AsHeaderName}, 7 + request::Parts, 8 + }, 9 + }; 10 + use futures_util::future::BoxFuture; 11 + use gordian_auth::{ 12 + IntoVerificationKey as _, 13 + jwt::{Claims, Token, decode}, 14 + }; 15 + use gordian_identity::Resolver; 16 + use gordian_types::Nsid; 17 + use time::OffsetDateTime; 18 + 19 + use crate::{ 20 + model::{Knot, errors}, 21 + public::xrpc::XrpcError, 22 + }; 23 + 24 + #[derive(Debug, thiserror::Error)] 25 + #[error("transparent")] 26 + pub struct AuthorizationClaimsStoreError(pub Box<dyn std::error::Error>); 27 + 28 + pub trait AuthorizationClaimsStore<T>: Send + Sync { 29 + fn get_unexpired_claims<'a: 'b, 'b>( 30 + &'a self, 31 + jti: &'b str, 32 + now: i64, 33 + ) -> BoxFuture<'b, Result<Option<T>, AuthorizationClaimsStoreError>>; 34 + 35 + fn store_claims( 36 + &self, 37 + claims: T, 38 + now: i64, 39 + ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>>; 40 + } 41 + 42 + #[derive(Debug, thiserror::Error)] 43 + pub enum VerificationError { 44 + #[error("invalid lxm in authorization claims")] 45 + LexiconMethod, 46 + #[error("invalid iat in authorization claims")] 47 + UseBeforeIssue, 48 + #[error("invalid exp in authorization claims")] 49 + UseAfterExpiry, 50 + #[error("invalid aud in authorization claims")] 51 + WrongAudience, 52 + #[error("re-used authorization")] 53 + Reused, 54 + #[error("failed to read claims storage: {0}")] 55 + Storage(#[from] AuthorizationClaimsStoreError), 56 + } 57 + 58 + pub trait Verification: fmt::Debug + Send { 59 + const LEXICON_METHOD: &'static Nsid; 60 + 61 + fn verify_iat(now: i64, claims: &Claims) -> Result<i64, VerificationError> { 62 + match claims.iat { 63 + iat if iat <= now => Ok(iat), 64 + _ => Err(VerificationError::UseBeforeIssue), 65 + } 66 + } 67 + 68 + fn verify_exp(now: i64, claims: &Claims) -> Result<i64, VerificationError> { 69 + match claims.exp { 70 + exp if exp > now => Ok(exp), 71 + _ => Err(VerificationError::UseAfterExpiry), 72 + } 73 + } 74 + 75 + /// Verify [`Claims::lxm`] matches the required value. 76 + fn verify_lexicon_method(claims: &Claims) -> Result<&'static str, VerificationError> { 77 + match claims.lxm.as_deref() { 78 + Some(lxm) if lxm == Self::LEXICON_METHOD => Ok(Self::LEXICON_METHOD), 79 + _ => Err(VerificationError::LexiconMethod), 80 + } 81 + } 82 + 83 + fn verify_audience<'a>( 84 + audience: &gordian_types::Did, 85 + claims: &'a Claims, 86 + ) -> Result<&'a gordian_types::Did, VerificationError> { 87 + match claims.aud == audience { 88 + true => Ok(&claims.aud), 89 + false => Err(VerificationError::WrongAudience), 90 + } 91 + } 92 + 93 + fn verify_unique( 94 + store: &dyn AuthorizationClaimsStore<Claims>, 95 + now: i64, 96 + claims: &Claims, 97 + ) -> impl Future<Output = Result<(), VerificationError>> + Send { 98 + async move { 99 + match store.get_unexpired_claims(&claims.jti, now).await? { 100 + Some(stored_claims) if stored_claims.exp < now => Ok(()), 101 + None => Ok(()), 102 + _ => Err(VerificationError::Reused), 103 + } 104 + } 105 + } 106 + 107 + fn verify( 108 + store: &dyn AuthorizationClaimsStore<Claims>, 109 + now: i64, 110 + audience: &gordian_types::Did, 111 + claims: &Claims, 112 + ) -> impl Future<Output = Result<(), VerificationError>> + Send { 113 + async move { 114 + Self::verify_iat(now, claims)?; 115 + Self::verify_exp(now, claims)?; 116 + Self::verify_lexicon_method(claims)?; 117 + Self::verify_audience(audience, claims)?; 118 + Self::verify_unique(store, now, claims).await?; 119 + Ok(()) 120 + } 121 + } 122 + } 123 + 124 + /// Extracts and verifies the inter-service authorization from a request. 125 + #[derive(Clone, Debug)] 126 + pub struct Authorization<V: Verification> { 127 + claims: Claims, 128 + _phantom: std::marker::PhantomData<V>, 129 + } 130 + 131 + impl<V: Verification> Authorization<V> { 132 + fn new(claims: Claims) -> Self { 133 + Self { 134 + claims, 135 + _phantom: std::marker::PhantomData, 136 + } 137 + } 138 + 139 + /// Extract the verified authorization claims. 140 + #[inline] 141 + pub fn claims(self) -> Claims { 142 + self.claims 143 + } 144 + } 145 + 146 + impl<S: Sync, V: Verification> FromRequestParts<S> for Authorization<V> 147 + where 148 + Knot: FromRef<S>, 149 + Resolver: FromRef<S>, 150 + { 151 + type Rejection = XrpcError; 152 + 153 + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 154 + let knot = Knot::from_ref(state); 155 + let resolver = Resolver::from_ref(state); 156 + let now = OffsetDateTime::now_utc().unix_timestamp(); 157 + 158 + let credential = extract_token(parts, AUTHORIZATION, "bearer") 159 + .ok_or(errors::Unauthorized("inter-service authorization required"))?; 160 + 161 + let unverified_token = Token::decode_unverified(credential) 162 + .map_err(|_| errors::Unauthorized("inter-service authorization required"))?; 163 + 164 + // Before performing a relatively expensive DID look-up, ensure the token 165 + // claims are valid. 166 + let unverified_claims = unverified_token.claims; 167 + V::verify(&knot, now, knot.instance(), &unverified_claims) 168 + .await 169 + .map_err(errors::Forbidden)?; 170 + 171 + // Resolve the DID document for the claimed issuer, extract and parse 172 + // the verification methods into public keys. 173 + 174 + let (resolved_did, doc) = resolver 175 + .resolve(unverified_claims.iss.as_str()) 176 + .await 177 + .map_err(errors::Forbidden)?; 178 + 179 + assert_eq!(unverified_claims.iss, resolved_did); 180 + 181 + // @QUESTION Should we check all verification keys, or just the first? 182 + let verification_keys = doc 183 + .verification_method 184 + .into_iter() 185 + .filter_map(|vm| vm.into_verification_key().ok()); 186 + 187 + // @TODO (Maybe) allow the Verifier to restrict acceptable signature algorithms. 188 + 189 + // Try to decode and verify the JWT using any one of the public keys 190 + // we have for the DID. 191 + for verification_key in verification_keys { 192 + if let Ok(token) = decode::<Claims>(credential, &verification_key) { 193 + let claims = token.claims; 194 + 195 + // Store the JWT so it cannot be re-used. 196 + knot.store_claims(claims.clone(), now) 197 + .await 198 + .map_err(errors::Internal)?; 199 + 200 + return Ok(Self::new(claims)); 201 + } 202 + } 203 + 204 + Err(errors::Forbidden( 205 + "failed to verifiy inter-service authorization", 206 + ))? 207 + } 208 + } 209 + 210 + pub fn extract_token<'a>( 211 + parts: &'a Parts, 212 + header_name: impl AsHeaderName, 213 + match_scheme: &str, 214 + ) -> Option<&'a str> { 215 + for header_value in parts.headers.get_all(header_name) { 216 + let Ok(s) = std::str::from_utf8(header_value.as_bytes()) else { 217 + continue; 218 + }; 219 + 220 + let mut header_parts = s.split_ascii_whitespace(); 221 + match ( 222 + header_parts.next(), 223 + header_parts.next(), 224 + header_parts.next(), 225 + ) { 226 + (Some(scheme), Some(credential), None) if scheme.to_lowercase() == match_scheme => { 227 + return Some(credential); 228 + } 229 + _ => continue, 230 + } 231 + } 232 + 233 + None 234 + }
+551
crates/gordian-knot/src/services/database.rs
··· 1 + // mod pg_impl; 2 + pub mod types; 3 + 4 + use futures_util::{StreamExt, stream::BoxStream}; 5 + use gordian_auth::jwt; 6 + use gordian_jetstream::Value; 7 + use gordian_lexicon::sh_tangled::{PublicKey, knot::Member, repo::Repo}; 8 + use gordian_types::{Did, OwnedDid}; 9 + use serde::Serialize; 10 + use sqlx::{SqlitePool, error::ErrorKind}; 11 + use time::OffsetDateTime; 12 + use types::{DeletedRecord, EventRow}; 13 + 14 + use crate::types::RecordKey; 15 + 16 + #[derive(Debug, thiserror::Error)] 17 + pub enum DataStoreError { 18 + #[error("Database error: {0}")] 19 + Sqlx(#[from] sqlx::Error), 20 + #[error("Failed to parse DID from db: {0}")] 21 + Did(#[from] gordian_types::did::Error), 22 + #[error("Failed to extract AT-URI: {0}")] 23 + AtUri(#[from] gordian_types::aturi::Error), 24 + #[error(transparent)] 25 + DateTime(#[from] time::error::ComponentRange), 26 + #[error("Invalid JSON data in database: {0}")] 27 + Json(#[from] serde_json::Error), 28 + #[error("{0}")] 29 + Other(#[from] anyhow::Error), 30 + } 31 + 32 + #[derive(Clone, Debug)] 33 + pub struct DataStore { 34 + db: SqlitePool, 35 + } 36 + 37 + impl DataStore { 38 + pub fn new(db: SqlitePool) -> Self { 39 + Self { db } 40 + } 41 + 42 + pub async fn get_jetstream_cursor(&self) -> Result<Option<OffsetDateTime>, DataStoreError> { 43 + let result = sqlx::query!("SELECT cursor FROM jetstream_cursor WHERE id = 1") 44 + .fetch_optional(&self.db) 45 + .await?; 46 + 47 + let cursor = result 48 + .map(|record| { 49 + OffsetDateTime::from_unix_timestamp_nanos(i128::from(record.cursor) * 1000) 50 + }) 51 + .transpose()?; 52 + 53 + Ok(cursor) 54 + } 55 + 56 + pub async fn store_jetstream_cursor( 57 + &self, 58 + cursor: OffsetDateTime, 59 + ) -> Result<(), DataStoreError> { 60 + let cursor = i64::try_from(cursor.unix_timestamp_nanos() / 1000).unwrap(); 61 + sqlx::query!("INSERT INTO jetstream_cursor (id, cursor) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor", cursor).execute(&self.db).await?; 62 + Ok(()) 63 + } 64 + 65 + pub fn knot_members(&self) -> BoxStream<'_, Result<OwnedDid, DataStoreError>> { 66 + sqlx::query!(r#"SELECT subject AS "subject: OwnedDid" FROM knot_member ORDER BY rkey, rev"#) 67 + .fetch(&self.db) 68 + .map(|record| Ok(record?.subject)) 69 + .boxed() 70 + } 71 + 72 + /// Get all the knot members and repository collaborators associated with the knot. 73 + pub fn members(&self) -> BoxStream<'_, Result<OwnedDid, DataStoreError>> { 74 + sqlx::query!(r#"SELECT DISTINCT subject AS "subject: OwnedDid" FROM knot_member UNION SELECT DISTINCT subject AS "subject: OwnedDid" FROM repository_collaborator"#) 75 + .fetch(&self.db) 76 + .map(|record| Ok(record?.subject)) 77 + .boxed() 78 + } 79 + 80 + /// Upsert a knot member. 81 + /// 82 + /// Returns `true` if the member record was newly inserted/updated, or `false` if 83 + /// the member was already present in the database. 84 + /// 85 + pub async fn upsert_knot_member( 86 + &self, 87 + rkey: &str, 88 + rev: &str, 89 + cid: &str, 90 + member: &Member<'_>, 91 + ) -> Result<bool, DataStoreError> { 92 + let subject = member.subject.as_ref(); 93 + let result = sqlx::query!( 94 + "INSERT INTO knot_member 95 + (rkey, rev, cid, subject, domain, created_at) 96 + VALUES (?, ?, ?, ?, ?, ?) 97 + ON CONFLICT (rkey) 98 + DO UPDATE 99 + SET 100 + rev = excluded.rev, 101 + cid = excluded.cid, 102 + subject = excluded.subject, 103 + domain = excluded.domain, 104 + created_at = excluded.created_at 105 + WHERE excluded.rev > knot_member.rev 106 + RETURNING rkey", 107 + rkey, 108 + rev, 109 + cid, 110 + subject, 111 + member.domain, 112 + member.created_at 113 + ) 114 + .fetch_optional(&self.db) 115 + .await?; 116 + 117 + Ok(result.is_some()) 118 + } 119 + 120 + pub async fn delete_knot_member( 121 + &self, 122 + rkey: &str, 123 + rev: &str, 124 + ) -> Result<Option<OwnedDid>, DataStoreError> { 125 + let result = sqlx::query!( 126 + r#"DELETE FROM knot_member WHERE rkey = ? AND rev <= ? RETURNING subject AS "subject: OwnedDid""#, 127 + rkey, 128 + rev 129 + ) 130 + .fetch_optional(&self.db) 131 + .await?; 132 + 133 + Ok(result.map(|record| record.subject)) 134 + } 135 + 136 + /// Upsert a repository collaborator. 137 + /// 138 + /// Returns `true` if the collaborator record was newly inserted/updated, or `false` if 139 + /// the collaborator was already present in the database. 140 + /// 141 + pub async fn upsert_repository_collaborator( 142 + &self, 143 + did: &Did, 144 + rkey: &str, 145 + rev: &str, 146 + cid: &str, 147 + repo_did: &Did, 148 + repo_rkey: &str, 149 + subject: &Did, 150 + created_at: OffsetDateTime, 151 + ) -> Result<bool, DataStoreError> { 152 + let result = sqlx::query!( 153 + "INSERT INTO repository_collaborator 154 + (did, rkey, rev, cid, repo_did, repo_rkey, subject, created_at) 155 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 156 + ON CONFLICT (did, rkey) 157 + DO UPDATE 158 + SET 159 + rev = excluded.rev, 160 + cid = excluded.cid, 161 + repo_did = excluded.repo_did, 162 + repo_rkey = excluded.repo_rkey, 163 + subject = excluded.subject, 164 + created_at = excluded.created_at 165 + WHERE excluded.rev > repository_collaborator.rev 166 + RETURNING rkey", 167 + did, 168 + rkey, 169 + rev, 170 + cid, 171 + repo_did, 172 + repo_rkey, 173 + subject, 174 + created_at 175 + ) 176 + .fetch_optional(&self.db) 177 + .await?; 178 + 179 + Ok(result.is_some()) 180 + } 181 + 182 + pub async fn get_repository_collaborator_record( 183 + &self, 184 + did: &Did, 185 + rkey: &str, 186 + ) -> Result<(OwnedDid, String, OwnedDid), DataStoreError> { 187 + let result = sqlx::query!( 188 + r#"SELECT repo_did as "repo_did: OwnedDid", repo_rkey, subject as "subject: OwnedDid" FROM repository_collaborator WHERE did = ? AND rkey = ?"#, 189 + did, 190 + rkey 191 + ) 192 + .fetch_one(&self.db) 193 + .await?; 194 + 195 + Ok((result.repo_did, result.repo_rkey, result.subject)) 196 + } 197 + 198 + pub async fn delete_repository_collaborator( 199 + &self, 200 + did: &Did, 201 + rkey: &str, 202 + rev: &str, 203 + ) -> Result<Option<OwnedDid>, DataStoreError> { 204 + let result = sqlx::query!( 205 + r#"DELETE FROM repository_collaborator WHERE did = ? AND rkey = ? AND rev <= ? RETURNING subject AS "subject: OwnedDid""#, 206 + did, 207 + rkey, 208 + rev 209 + ) 210 + .fetch_optional(&self.db) 211 + .await?; 212 + 213 + Ok(result.map(|record| record.subject)) 214 + } 215 + 216 + /// Get the OpenSSH public keys for the specified DID. 217 + pub async fn public_keys_for_did( 218 + &self, 219 + did: &Did, 220 + ) -> Result<Vec<PublicKey<'static>>, DataStoreError> { 221 + let keys = sqlx::query_as!( 222 + PublicKey, 223 + "SELECT name, key, created_at FROM public_key WHERE did = ? ORDER BY rkey, rev", 224 + did 225 + ) 226 + .fetch_all(&self.db) 227 + .await?; 228 + 229 + Ok(keys) 230 + } 231 + 232 + pub async fn upsert_public_key( 233 + &self, 234 + did: &Did, 235 + rkey: &str, 236 + rev: &str, 237 + cid: &str, 238 + public_key: &PublicKey<'_>, 239 + ) -> Result<(), DataStoreError> { 240 + sqlx::query!( 241 + "INSERT INTO public_key 242 + (did, rkey, rev, cid, name, key, created_at) 243 + VALUES (?, ?, ?, ?, ?, ?, ?) 244 + ON CONFLICT (did, rkey) 245 + DO UPDATE 246 + SET 247 + rev = excluded.rev, 248 + cid = excluded.cid, 249 + name = excluded.name, 250 + key = excluded.key, 251 + created_at = excluded.created_at 252 + WHERE excluded.rev > public_key.rev", 253 + did, 254 + rkey, 255 + rev, 256 + cid, 257 + public_key.name, 258 + public_key.key, 259 + public_key.created_at 260 + ) 261 + .execute(&self.db) 262 + .await?; 263 + 264 + Ok(()) 265 + } 266 + 267 + pub async fn delete_public_key( 268 + &self, 269 + did: &Did, 270 + rkey: &str, 271 + rev: &str, 272 + ) -> Result<Option<DeletedRecord>, DataStoreError> { 273 + let record = sqlx::query!( 274 + r#"DELETE FROM public_key WHERE did = ? AND rkey = ? AND rev <= ? RETURNING did AS "did: OwnedDid", rkey, rev, cid"#, 275 + did, 276 + rkey, 277 + rev 278 + ) 279 + .fetch_optional(&self.db) 280 + .await?; 281 + 282 + Ok(match record { 283 + Some(record) => Some(DeletedRecord { 284 + did: record.did, 285 + rkey: record.rkey, 286 + rev: record.rev, 287 + cid: record.cid, 288 + }), 289 + None => None, 290 + }) 291 + } 292 + 293 + pub async fn update_repository( 294 + &self, 295 + did: &Did, 296 + rkey: &str, 297 + rev: &str, 298 + cid: &str, 299 + repository: &Repo<'_>, 300 + ) -> Result<(), DataStoreError> { 301 + sqlx::query!( 302 + "UPDATE repository \ 303 + SET rev = ?,\ 304 + cid = ?,\ 305 + name = ?,\ 306 + knot = ?,\ 307 + spindle = ?,\ 308 + source = ?,\ 309 + created_at = ?\ 310 + WHERE 311 + did = ? 312 + AND rkey = ? 313 + AND rev <= ?", 314 + rev, 315 + cid, 316 + repository.name, 317 + repository.knot, 318 + repository.spindle, 319 + repository.source, 320 + repository.created_at, 321 + did, 322 + rkey, 323 + rev 324 + ) 325 + .execute(&self.db) 326 + .await?; 327 + 328 + Ok(()) 329 + } 330 + 331 + pub async fn resolve_repository( 332 + &self, 333 + did: &Did, 334 + name_or_rkey: &str, 335 + ) -> Result<Option<(String, String)>, DataStoreError> { 336 + let resolved = sqlx::query!( 337 + "SELECT rkey, name FROM repository WHERE did = ? AND (name = ? OR rkey = ?)", 338 + did, 339 + name_or_rkey, 340 + name_or_rkey 341 + ) 342 + .fetch_optional(&self.db) 343 + .await?; 344 + 345 + tracing::debug!(?resolved); 346 + 347 + Ok(resolved.map(|record| (record.rkey, record.name))) 348 + } 349 + 350 + pub async fn is_repository_member( 351 + &self, 352 + repo_did: &Did, 353 + repo_rkey: &str, 354 + subject: &Did, 355 + ) -> Result<bool, DataStoreError> { 356 + let member = sqlx::query!( 357 + r#"SELECT repo_did as "repo_did: OwnedDid", repo_rkey, subject as "subject: OwnedDid" FROM repository_collaborator WHERE repo_did = ? AND repo_rkey = ? AND subject = ?"#, 358 + repo_did, 359 + repo_rkey, 360 + subject 361 + ).fetch_optional(&self.db).await?; 362 + 363 + Ok(member.is_some_and(|record| { 364 + record.repo_did == repo_did 365 + && record.repo_rkey == repo_rkey 366 + && record.subject == subject 367 + })) 368 + } 369 + 370 + pub async fn is_knot_member(&self, did: &Did) -> Result<bool, DataStoreError> { 371 + let member = sqlx::query!( 372 + r#"SELECT subject as "subject: OwnedDid" FROM knot_member WHERE subject = ? LIMIT 1"#, 373 + did, 374 + ) 375 + .fetch_optional(&self.db) 376 + .await?; 377 + 378 + Ok(member 379 + .map(|record| record.subject == did) 380 + .unwrap_or_default()) 381 + } 382 + 383 + pub async fn insert_event<T>( 384 + &self, 385 + ts: OffsetDateTime, 386 + repo_did: &Did, 387 + repo_rkey: &str, 388 + collection: &str, 389 + event: &T, 390 + ) -> Result<i64, DataStoreError> 391 + where 392 + T: Serialize, 393 + { 394 + let record = serde_json::to_value(event).unwrap(); 395 + let result = sqlx::query!( 396 + "INSERT INTO event (ts, repo_did, repo_rkey, collection, record) VALUES (?, ?, ?, ?, ?) RETURNING id", 397 + ts, 398 + repo_did, 399 + repo_rkey, 400 + collection, 401 + record 402 + ).fetch_one(&self.db).await?; 403 + 404 + Ok(result.id) 405 + } 406 + 407 + pub fn get_events<'a: 'b, 'b>( 408 + &'a self, 409 + from: &'b OffsetDateTime, 410 + ) -> BoxStream<'b, Result<EventRow, DataStoreError>> { 411 + sqlx::query_as!( 412 + EventRow, 413 + r#"SELECT id, ts, collection, record as "record: Value" FROM event WHERE ts >= ? ORDER BY id"#, 414 + *from 415 + ) 416 + .fetch(&self.db) 417 + .map(|record| { 418 + let record = record?; 419 + Ok(record) 420 + }) 421 + .boxed() 422 + } 423 + 424 + pub async fn store_claims(&self, claims: jwt::Claims, now: i64) -> Result<(), DataStoreError> { 425 + let mut transaction = self.db.begin().await?; 426 + 427 + // First delete any expired claims. 428 + sqlx::query!( 429 + "DELETE FROM claim WHERE json_extract(claims, '$.exp') < ?", 430 + now 431 + ) 432 + .execute(&mut *transaction) 433 + .await?; 434 + 435 + let id = &claims.jti; 436 + let claims = serde_json::to_value(&claims)?; 437 + sqlx::query!("INSERT INTO claim (id, claims) VALUES (?, ?)", id, claims) 438 + .execute(&mut *transaction) 439 + .await?; 440 + 441 + transaction.commit().await?; 442 + Ok(()) 443 + } 444 + 445 + pub async fn get_claims( 446 + &self, 447 + id: &str, 448 + now: i64, 449 + ) -> Result<Option<jwt::Claims>, DataStoreError> { 450 + let claims = sqlx::query!( 451 + r#"SELECT claims as "claims: Value" FROM claim WHERE id = ? AND json_extract(claims, '$.exp') >= ?"#, 452 + id, now 453 + ) 454 + .fetch_optional(&self.db) 455 + .await? 456 + .map(|record| serde_json::from_value::<jwt::Claims>(record.claims)) 457 + .transpose()?; 458 + 459 + Ok(claims) 460 + } 461 + 462 + pub async fn delete_claims(&self, id: &str) -> Result<(), DataStoreError> { 463 + sqlx::query!("DELETE FROM claim WHERE id = ?", id) 464 + .execute(&self.db) 465 + .await?; 466 + Ok(()) 467 + } 468 + 469 + pub async fn begin<'db>(&'db self) -> Result<DatabaseTransaction<'db>, DataStoreError> { 470 + let tx = self.db.begin().await?; 471 + Ok(DatabaseTransaction { tx }) 472 + } 473 + } 474 + 475 + pub struct DatabaseTransaction<'db> { 476 + tx: sqlx::SqliteTransaction<'db>, 477 + } 478 + 479 + impl<'db> DatabaseTransaction<'db> { 480 + pub async fn commit(self) -> Result<(), DataStoreError> { 481 + Ok(self.tx.commit().await?) 482 + } 483 + 484 + /// Insert a new repository entry from a jetstream commit, returning `true` if the repository 485 + /// appears to be new. 486 + /// 487 + /// # Note 488 + /// 489 + /// This is *not* an UPSERT. 490 + /// 491 + pub async fn insert_repository( 492 + &mut self, 493 + did: &Did, 494 + rkey: &str, 495 + rev: &str, 496 + cid: &str, 497 + repository: &Repo<'_>, 498 + ) -> Result<bool, DataStoreError> { 499 + let result = sqlx::query!( 500 + "INSERT INTO repository (did, rkey, rev, cid, name, knot, spindle, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 501 + did, 502 + rkey, 503 + rev, 504 + cid, 505 + repository.name, 506 + repository.knot, 507 + repository.spindle, 508 + repository.source, 509 + repository.created_at 510 + ).fetch_optional(&mut *self.tx).await; 511 + 512 + match result { 513 + Ok(_) => Ok(true), 514 + Err(error) => match error.as_database_error() { 515 + Some(database_error) if database_error.kind() == ErrorKind::UniqueViolation => { 516 + Ok(false) 517 + } 518 + _ => Err(error)?, 519 + }, 520 + } 521 + } 522 + 523 + pub async fn delete_repository( 524 + &mut self, 525 + rec: &RecordKey<'_>, 526 + ) -> Result<Option<(DeletedRecord, String)>, DataStoreError> { 527 + assert_eq!(rec.collection, "sh.tangled.repo"); 528 + 529 + let record = sqlx::query!( 530 + r#"DELETE FROM repository WHERE did = ? AND rkey = ? AND rev <= ? RETURNING did AS "did: OwnedDid", rkey, rev, cid, name"#, 531 + rec.did, 532 + rec.rkey, 533 + rec.rev 534 + ) 535 + .fetch_optional(&mut *self.tx) 536 + .await?; 537 + 538 + Ok(match record { 539 + Some(record) => Some(( 540 + DeletedRecord { 541 + did: record.did, 542 + rkey: record.rkey, 543 + rev: record.rev, 544 + cid: record.cid, 545 + }, 546 + record.name, 547 + )), 548 + None => None, 549 + }) 550 + } 551 + }
+84
crates/gordian-knot/src/services/database/types.rs
··· 1 + use crate::lexicon::sh_tangled::PublicKey; 2 + use gordian_types::{Did, OwnedDid}; 3 + use time::OffsetDateTime; 4 + 5 + /// A flattened public key record. 6 + pub struct PublicKeyRecordRef<'a> { 7 + pub did: &'a Did, 8 + pub rkey: &'a str, 9 + pub cid: &'a str, 10 + pub name: &'a str, 11 + pub key: &'a str, 12 + pub created_at: &'a OffsetDateTime, 13 + } 14 + 15 + impl<'a> From<(&'a gordian_jetstream::Commit<'a>, &'a PublicKey<'a>)> for PublicKeyRecordRef<'a> { 16 + fn from((commit, key): (&'a gordian_jetstream::Commit<'a>, &'a PublicKey<'a>)) -> Self { 17 + Self { 18 + did: commit.did, 19 + rkey: commit.rkey, 20 + cid: commit.cid, 21 + name: &key.name, 22 + key: &key.key, 23 + created_at: &key.created_at, 24 + } 25 + } 26 + } 27 + 28 + /// An owned, flattened public key record. 29 + pub struct PublicKeyRecord { 30 + pub did: String, 31 + pub rkey: String, 32 + pub cid: String, 33 + pub name: String, 34 + pub key: String, 35 + pub created_at: OffsetDateTime, 36 + } 37 + 38 + impl<'a> From<(&'a gordian_jetstream::Commit<'a>, PublicKey<'a>)> for PublicKeyRecord { 39 + fn from((commit, key): (&'a gordian_jetstream::Commit<'a>, PublicKey<'a>)) -> Self { 40 + Self { 41 + did: commit.did.as_str().into(), 42 + rkey: commit.rkey.into(), 43 + cid: commit.cid.into(), 44 + name: key.name.into(), 45 + key: key.key.into(), 46 + created_at: key.created_at, 47 + } 48 + } 49 + } 50 + 51 + impl From<PublicKeyRecord> for PublicKey<'static> { 52 + fn from(value: PublicKeyRecord) -> Self { 53 + Self { 54 + key: value.key.into(), 55 + name: value.name.into(), 56 + created_at: value.created_at, 57 + } 58 + } 59 + } 60 + 61 + #[derive(Debug, sqlx::FromRow)] 62 + pub struct InsertRepositoryResult { 63 + pub name: String, 64 + pub old_xrpc_create_at: Option<OffsetDateTime>, 65 + pub new_xrpc_create_at: Option<OffsetDateTime>, 66 + pub old_jetstream_at: Option<OffsetDateTime>, 67 + pub new_jetstream_at: Option<OffsetDateTime>, 68 + } 69 + 70 + #[derive(Debug)] 71 + pub struct EventRow { 72 + pub id: i64, 73 + pub ts: OffsetDateTime, 74 + pub collection: String, 75 + pub record: serde_json::Value, 76 + } 77 + 78 + #[derive(Debug)] 79 + pub struct DeletedRecord { 80 + pub did: OwnedDid, 81 + pub rkey: String, 82 + pub rev: String, 83 + pub cid: String, 84 + }
+424
crates/gordian-knot/src/services/jetstream.rs
··· 1 + use crate::{ 2 + lexicon::Lexicon, 3 + model::{Knot, KnotState}, 4 + services::rbac::{ 5 + Action, AddCollaboratorPolicy, AddMemberPolicy, Policy, PolicyResult::*, 6 + RemoveCollaboratorPolicy, RemoveMemberPolicy, RepositoryCreatePolicy, 7 + RepositoryDeletePolicy, RepositoryRef, 8 + }, 9 + }; 10 + use futures_util::StreamExt as _; 11 + use gordian_jetstream::{CommitEvent, Event, JetstreamClient, client_config::JetstreamConfig}; 12 + use std::{borrow::Cow, time::Duration}; 13 + use tokio::time::Instant; 14 + use tokio_util::sync::CancellationToken; 15 + 16 + pub fn init_consumer<T: AsRef<str>>( 17 + knot: &Knot, 18 + instances: &[T], 19 + shutdown: CancellationToken, 20 + ) -> impl Future<Output = anyhow::Result<()>> + use<T> { 21 + let knot = knot.clone(); 22 + let jetstream_instances: Vec<_> = instances 23 + .iter() 24 + .filter(|s| !s.as_ref().is_empty()) 25 + .map(|url| Cow::Owned(url.as_ref().to_string())) 26 + .collect(); 27 + 28 + async move { 29 + if jetstream_instances.is_empty() { 30 + tracing::warn!("no jetstream instances provided"); 31 + return Ok(()); 32 + } 33 + 34 + let cursor = knot 35 + .database() 36 + .get_jetstream_cursor() 37 + .await? 38 + .map(|odt| (odt, (odt.unix_timestamp_nanos() / 1000).unsigned_abs())); 39 + 40 + if let Some((cursor, cursor_us)) = &cursor { 41 + tracing::info!(?cursor, ?cursor_us, "found jetstream cursor"); 42 + } 43 + 44 + let mut config = JetstreamConfig::default() 45 + .with_instances(jetstream_instances) 46 + .with_cursor(cursor.map(|(_, us)| us)) 47 + .with_collections([ 48 + "sh.tangled.knot.member", 49 + "sh.tangled.publicKey", 50 + "sh.tangled.repo", 51 + "sh.tangled.repo.collaborator", 52 + ]); 53 + 54 + { 55 + let mut member_dids = knot.database().members(); 56 + while let Some(did) = member_dids.next().await { 57 + config 58 + .subscriber_options 59 + .add_did(did?) 60 + .expect("knot members shouldn't exceed maximum DID filters"); 61 + } 62 + } 63 + 64 + let (jetstream, jetstream_rx, jetstream_rx_task) = config.connect(); 65 + let (consumer_result, jetstream_result) = tokio::join!( 66 + tokio::spawn(consume(jetstream, knot, jetstream_rx, shutdown)), 67 + tokio::spawn(jetstream_rx_task) 68 + ); 69 + 70 + consumer_result?; 71 + jetstream_result?; 72 + 73 + Ok(()) 74 + } 75 + } 76 + 77 + pub async fn consume( 78 + client: JetstreamClient, 79 + knot: Knot, 80 + jetstream_rx: gordian_jetstream::JetstreamReceiver, 81 + shutdown: CancellationToken, 82 + ) { 83 + let mut last_jetstream_sync: Option<Instant> = None; 84 + 85 + while let Some(Some(raw_event)) = shutdown 86 + .run_until_cancelled(jetstream_rx.recv_async()) 87 + .await 88 + { 89 + let event = match raw_event.deserialize() { 90 + Ok(event) => event, 91 + Err(error) => { 92 + let msg = String::from_utf8_lossy(raw_event.as_bytes()); 93 + tracing::error!(?error, %msg, "failed to deserialize event"); 94 + continue; 95 + } 96 + }; 97 + 98 + tracing::debug!(?event); 99 + 100 + match &event { 101 + Event::Commit(commit) => match commit.collection() { 102 + "sh.tangled.knot.member" => { 103 + if let Err(error) = process_knot_member(&client, &knot, commit).await { 104 + tracing::error!(?error, "failed to process 'sh.tangled.knot_member' record") 105 + } 106 + } 107 + "sh.tangled.publicKey" => { 108 + if let Err(error) = process_public_key(&client, &knot, commit).await { 109 + tracing::error!(?error, "failed to process 'sh.tangled.publicKey' record") 110 + } 111 + } 112 + "sh.tangled.repo" => { 113 + if let Err(error) = process_repo(&client, &knot, commit).await { 114 + tracing::error!(?error, "failed to process 'sh.tangled.repo' record") 115 + } 116 + } 117 + "sh.tangled.repo.collaborator" => { 118 + if let Err(error) = process_repo_collaborator(&client, &knot, commit).await { 119 + tracing::error!( 120 + ?error, 121 + "failed to process 'sh.tangled.repo.collaborator' record" 122 + ) 123 + } 124 + } 125 + collection => unimplemented!("no handler implemented for '{collection}' commits"), 126 + }, 127 + Event::Account(account) => { 128 + knot.resolver().invalidate_did(account.did).await; 129 + } 130 + Event::Identity(identity) => { 131 + knot.resolver().invalidate_did(identity.did).await; 132 + } 133 + } 134 + 135 + if last_jetstream_sync.is_none_or(|value| value.elapsed() > Duration::from_secs(1)) { 136 + match knot.database().store_jetstream_cursor(event.ts()).await { 137 + Ok(()) => last_jetstream_sync = Some(Instant::now()), 138 + Err(error) => tracing::error!(?error, "failed to log jetstream event"), 139 + }; 140 + } 141 + } 142 + 143 + tracing::warn!("jetstream consumer finished"); 144 + } 145 + 146 + #[tracing::instrument(skip(knot))] 147 + async fn process_public_key<'db, 'c, 'k>( 148 + _: &JetstreamClient, 149 + knot: &KnotState, 150 + event: &'c CommitEvent<'c>, 151 + ) -> anyhow::Result<()> 152 + where 153 + 'c: 'k, 154 + 'db: 'c, 155 + { 156 + match event { 157 + CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 158 + let Lexicon::PublicKey(key) = serde_json::from_str(commit.record.get())? else { 159 + return Err(anyhow::anyhow!("expected a 'sh.tangled.publicKey' record")); 160 + }; 161 + 162 + if !knot.database().is_knot_member(event.did()).await? { 163 + return Ok(()); 164 + } 165 + 166 + knot.database() 167 + .upsert_public_key(commit.did, commit.rkey, commit.rev, commit.cid, &key) 168 + .await?; 169 + } 170 + CommitEvent::Delete(delete) => { 171 + assert_eq!(delete.collection, "sh.tangled.publicKey"); 172 + knot.database() 173 + .delete_public_key(delete.did, delete.rkey, delete.rev) 174 + .await?; 175 + } 176 + } 177 + 178 + tracing::info!("ingested 'sh.tangled.publicKey' from jetstream"); 179 + Ok(()) 180 + } 181 + 182 + #[tracing::instrument(skip(knot))] 183 + async fn process_repo( 184 + _: &JetstreamClient, 185 + knot: &KnotState, 186 + event: &CommitEvent<'_>, 187 + ) -> anyhow::Result<()> { 188 + match event { 189 + CommitEvent::Create(commit) => { 190 + let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 191 + return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 192 + }; 193 + 194 + if repository.knot != knot.instance_ident() { 195 + tracing::debug!( 196 + did = %event.did(), 197 + rkey = %event.rkey(), 198 + name = %repository.name, 199 + "repository is not for this knot, ignoring" 200 + ); 201 + return Ok(()); 202 + } 203 + 204 + let policy = RepositoryCreatePolicy; 205 + let can_create = policy 206 + .evaluate_access(&commit.did, &Action::RepositoryCreate, knot, knot) 207 + .await; 208 + 209 + if !matches!(can_create, Granted) { 210 + tracing::warn!(?commit, "RepositoryCreate permission denied"); 211 + return Ok(()); 212 + } 213 + 214 + knot.create_repo(&commit.try_into()?, &repository) 215 + .await 216 + .inspect_err(|error| tracing::error!(?error, "failed to create repository"))?; 217 + } 218 + CommitEvent::Update(commit) => { 219 + let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 220 + return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 221 + }; 222 + 223 + if repository.knot != knot.instance_ident() { 224 + tracing::debug!( 225 + did = %event.did(), 226 + rkey = %event.rkey(), 227 + name = %repository.name, 228 + "repository is not for this knot, ignoring" 229 + ); 230 + return Ok(()); 231 + } 232 + 233 + // @TODO Does this need auth? 234 + 235 + knot.database() 236 + .update_repository(commit.did, commit.rkey, commit.rev, commit.cid, &repository) 237 + .await?; 238 + } 239 + CommitEvent::Delete(commit) => { 240 + // First determine whether the repository exists on this knot 241 + if knot 242 + .database() 243 + .resolve_repository(commit.did, commit.rkey) 244 + .await? 245 + .is_none() 246 + { 247 + tracing::debug!( 248 + did = %event.did(), 249 + rkey = %event.rkey(), 250 + "repository is not for this knot, ignoring" 251 + ); 252 + return Ok(()); 253 + }; 254 + 255 + let policy = RepositoryDeletePolicy; 256 + let repository = RepositoryRef::new(commit.did, commit.rkey); 257 + let can_create = policy 258 + .evaluate_access(&commit.did, &Action::RepositoryDelete, &repository, knot) 259 + .await; 260 + 261 + if !matches!(can_create, Granted) { 262 + tracing::warn!(?commit, "RepositoryDelete permission denied"); 263 + return Ok(()); 264 + } 265 + 266 + knot.delete_repo(&commit.try_into()?).await?; 267 + } 268 + } 269 + 270 + tracing::info!("ingested 'sh.tangled.repo' from jetstream"); 271 + Ok(()) 272 + } 273 + 274 + #[tracing::instrument(skip(jetstream, knot))] 275 + async fn process_knot_member( 276 + jetstream: &JetstreamClient, 277 + knot: &Knot, 278 + event: &CommitEvent<'_>, 279 + ) -> anyhow::Result<()> { 280 + match event { 281 + CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 282 + let policy = AddMemberPolicy; 283 + let can_add_knot_member = policy 284 + .evaluate_access(&commit.did, &Action::KnotMemberCreate, knot, knot) 285 + .await; 286 + 287 + if !matches!(can_add_knot_member, Granted) { 288 + tracing::warn!(?commit, "AddKnotMember permission denied"); 289 + return Ok(()); 290 + } 291 + 292 + let Lexicon::KnotMember(member) = serde_json::from_str(commit.record.get())? else { 293 + return Err(anyhow::anyhow!( 294 + "expected a 'sh.tangled.knot.member' record" 295 + )); 296 + }; 297 + 298 + if member.domain != knot.instance_ident() { 299 + return Ok(()); 300 + } 301 + 302 + knot.add_member(commit.rkey, commit.rev, commit.cid, &member) 303 + .await?; 304 + 305 + // Start tracking the DID of the new member. 306 + jetstream.add_did(member.subject).await?; 307 + } 308 + CommitEvent::Delete(delete) => { 309 + let policy = RemoveMemberPolicy; 310 + let can_remove_knot_member = policy 311 + .evaluate_access(&delete.did, &Action::KnotMemberDelete, knot, knot) 312 + .await; 313 + 314 + if !matches!(can_remove_knot_member, Granted) { 315 + tracing::warn!(?delete, "RemoveKnotMember permission denied"); 316 + return Ok(()); 317 + } 318 + 319 + if let Some(did) = knot 320 + .database() 321 + .delete_knot_member(delete.rkey, delete.rev) 322 + .await? 323 + { 324 + // Stop tracking the DID of the new member. 325 + jetstream.remove_did(did).await?; 326 + } 327 + } 328 + } 329 + 330 + tracing::info!("ingested 'sh.tangled.knot.member' record from jetstream"); 331 + Ok(()) 332 + } 333 + 334 + #[tracing::instrument(skip(jetstream, knot))] 335 + async fn process_repo_collaborator( 336 + jetstream: &JetstreamClient, 337 + knot: &Knot, 338 + event: &CommitEvent<'_>, 339 + ) -> anyhow::Result<()> { 340 + match event { 341 + CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 342 + let Lexicon::RepoCollaborator(coll) = serde_json::from_str(commit.record.get())? else { 343 + return Err(anyhow::anyhow!( 344 + "expected a 'sh.tangled.repo.collaborator' record" 345 + )); 346 + }; 347 + 348 + let repo_did = coll.repo.authority(); 349 + let repo_rkey = coll.repo.rkey(); 350 + if coll.repo.collection() != "sh.tangled.repo" { 351 + return Err(anyhow::anyhow!( 352 + "repo parameter in should refer to a 'sh.tangled.repo'" 353 + )); 354 + } 355 + 356 + let policy = AddCollaboratorPolicy; 357 + let repository = RepositoryRef::new(repo_did, repo_rkey); 358 + let can_add_repo_collaborator = policy 359 + .evaluate_access( 360 + &commit.did, 361 + &Action::RepositoryCollaboratorAdd, 362 + &repository, 363 + knot, 364 + ) 365 + .await; 366 + 367 + if !matches!(can_add_repo_collaborator, Granted) { 368 + tracing::warn!(?commit, "RepositoryCollaboratorAdd permission denied"); 369 + return Ok(()); 370 + } 371 + 372 + if knot 373 + .database() 374 + .upsert_repository_collaborator( 375 + commit.did, 376 + commit.rkey, 377 + commit.rev, 378 + commit.cid, 379 + repo_did, 380 + repo_rkey, 381 + coll.subject, 382 + coll.created_at, 383 + ) 384 + .await? 385 + { 386 + jetstream.add_did(coll.subject).await?; 387 + crate::services::seed::public_keys(knot, coll.subject).await?; 388 + } 389 + } 390 + CommitEvent::Delete(delete) => { 391 + let (repo_did, repo_rkey, _) = knot 392 + .database() 393 + .get_repository_collaborator_record(delete.did, delete.rkey) 394 + .await?; 395 + 396 + let policy = RemoveCollaboratorPolicy; 397 + let repository = RepositoryRef::new(&repo_did, &repo_rkey); 398 + let can_remove_repo_collaborator = policy 399 + .evaluate_access( 400 + &delete.did, 401 + &Action::RepositoryCollaboratorDelete, 402 + &repository, 403 + knot, 404 + ) 405 + .await; 406 + 407 + if !matches!(can_remove_repo_collaborator, Granted) { 408 + tracing::warn!(?delete, "RepositoryCollaboratorDelete permission denied"); 409 + return Ok(()); 410 + } 411 + 412 + if let Some(did) = knot 413 + .database() 414 + .delete_repository_collaborator(delete.did, delete.rkey, delete.rev) 415 + .await? 416 + { 417 + jetstream.remove_did(did).await?; 418 + } 419 + } 420 + } 421 + 422 + tracing::info!("ingested 'sh.tangled.repo.collaborator' record from jetstream"); 423 + Ok(()) 424 + }
+254
crates/gordian-knot/src/services/rbac.rs
··· 1 + use futures_util::{FutureExt, future::BoxFuture}; 2 + use gordian_types::Did; 3 + 4 + use crate::{model::KnotState, types::repository_key::RepositoryKey}; 5 + 6 + pub trait Policy<Subject, Resource, Action, Context>: Send + Sync { 7 + /// Evaluates whether access should be granted. 8 + /// 9 + /// # Arguments 10 + /// 11 + /// * `subject` - The entity requesting access. 12 + /// * `action` - The action being performed. 13 + /// * `resource` - The target resource. 14 + /// * `context` - Additional context that may affect the decision. 15 + /// 16 + /// # Returns 17 + /// 18 + /// A [`PolicyResult`] indicating whether access is granted or denied. 19 + fn evaluate_access<'s: 'a, 'a>( 20 + &'s self, 21 + subject: &'a Subject, 22 + action: &'a Action, 23 + resource: &'a Resource, 24 + context: &'a Context, 25 + ) -> BoxFuture<'a, PolicyResult>; 26 + } 27 + 28 + impl<S, R, A, C> Policy<S, R, A, C> for Box<dyn Policy<S, R, A, C>> { 29 + #[inline] 30 + fn evaluate_access<'s: 'a, 'a>( 31 + &'s self, 32 + subject: &'a S, 33 + action: &'a A, 34 + resource: &'a R, 35 + context: &'a C, 36 + ) -> BoxFuture<'a, PolicyResult> { 37 + (**self).evaluate_access(subject, action, resource, context) 38 + } 39 + } 40 + 41 + pub enum PolicyResult { 42 + Granted, 43 + Denied, 44 + } 45 + 46 + pub enum Action { 47 + KnotMemberCreate, 48 + KnotMemberDelete, 49 + RepositoryCollaboratorAdd, 50 + RepositoryCollaboratorDelete, 51 + RepositoryCreate, 52 + RepositoryDelete, 53 + RepositoryEdit, 54 + RepositoryPush, 55 + } 56 + 57 + pub struct RepositoryPushPolicy; 58 + 59 + pub struct RepositoryRef<'a> { 60 + pub did: &'a Did, 61 + pub rkey: &'a str, 62 + } 63 + 64 + impl<'a> RepositoryRef<'a> { 65 + pub fn new(did: &'a Did, rkey: &'a str) -> Self { 66 + Self { did, rkey } 67 + } 68 + } 69 + 70 + impl<'a> From<&'a RepositoryKey> for RepositoryRef<'a> { 71 + fn from(RepositoryKey { owner: did, rkey }: &'a RepositoryKey) -> Self { 72 + Self { did, rkey } 73 + } 74 + } 75 + 76 + impl Policy<&Did, RepositoryKey, Action, KnotState> for RepositoryPushPolicy { 77 + fn evaluate_access<'s: 'a, 'a>( 78 + &'s self, 79 + &subject: &'a &Did, 80 + action: &'a Action, 81 + resource: &'a RepositoryKey, 82 + context: &'a KnotState, 83 + ) -> BoxFuture<'a, PolicyResult> { 84 + async move { 85 + let is_member = subject == resource.owner 86 + || context 87 + .database() 88 + .is_repository_member(&resource.owner, &resource.rkey, subject) 89 + .await 90 + .is_ok_and(|val| val); 91 + 92 + match (action, is_member) { 93 + (Action::RepositoryPush, true) => PolicyResult::Granted, 94 + (_, _) => PolicyResult::Denied, 95 + } 96 + } 97 + .boxed() 98 + } 99 + } 100 + 101 + pub struct AddMemberPolicy; 102 + 103 + impl Policy<&Did, KnotState, Action, KnotState> for AddMemberPolicy { 104 + fn evaluate_access<'s: 'a, 'a>( 105 + &'s self, 106 + &subject: &'a &Did, 107 + action: &'a Action, 108 + resource: &'a KnotState, 109 + _: &'a KnotState, 110 + ) -> BoxFuture<'a, PolicyResult> { 111 + async move { 112 + let is_owner = subject == resource.owner(); 113 + match (action, is_owner) { 114 + (Action::KnotMemberCreate, true) => PolicyResult::Granted, 115 + (_, _) => PolicyResult::Denied, 116 + } 117 + } 118 + .boxed() 119 + } 120 + } 121 + 122 + pub struct RemoveMemberPolicy; 123 + 124 + impl Policy<&Did, KnotState, Action, KnotState> for RemoveMemberPolicy { 125 + fn evaluate_access<'s: 'a, 'a>( 126 + &'s self, 127 + &subject: &'a &Did, 128 + action: &'a Action, 129 + resource: &'a KnotState, 130 + _: &'a KnotState, 131 + ) -> BoxFuture<'a, PolicyResult> { 132 + async move { 133 + let is_owner = subject == resource.owner(); 134 + match (action, is_owner) { 135 + (Action::KnotMemberDelete, true) => PolicyResult::Granted, 136 + (_, _) => PolicyResult::Denied, 137 + } 138 + } 139 + .boxed() 140 + } 141 + } 142 + 143 + pub struct AddCollaboratorPolicy; 144 + 145 + impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for AddCollaboratorPolicy { 146 + fn evaluate_access<'s: 'a, 'a>( 147 + &'s self, 148 + &subject: &'a &Did, 149 + action: &'a Action, 150 + resource: &'a RepositoryRef<'_>, 151 + _: &'a KnotState, 152 + ) -> BoxFuture<'a, PolicyResult> { 153 + // Only repository owners may add collaborators. 154 + async move { 155 + let is_owner = subject == resource.did; 156 + match (action, is_owner) { 157 + (Action::RepositoryCollaboratorAdd, true) => PolicyResult::Granted, 158 + (_, _) => PolicyResult::Denied, 159 + } 160 + } 161 + .boxed() 162 + } 163 + } 164 + 165 + pub struct RemoveCollaboratorPolicy; 166 + 167 + impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RemoveCollaboratorPolicy { 168 + fn evaluate_access<'s: 'a, 'a>( 169 + &'s self, 170 + &subject: &'a &Did, 171 + action: &'a Action, 172 + resource: &'a RepositoryRef<'_>, 173 + _: &'a KnotState, 174 + ) -> BoxFuture<'a, PolicyResult> { 175 + // Only repository owners may remove collaborators. 176 + async move { 177 + let is_owner = subject == resource.did; 178 + match (action, is_owner) { 179 + (Action::RepositoryCollaboratorDelete, true) => PolicyResult::Granted, 180 + (_, _) => PolicyResult::Denied, 181 + } 182 + } 183 + .boxed() 184 + } 185 + } 186 + 187 + pub struct RepositoryCreatePolicy; 188 + 189 + impl Policy<&Did, KnotState, Action, KnotState> for RepositoryCreatePolicy { 190 + fn evaluate_access<'s: 'a, 'a>( 191 + &'s self, 192 + &subject: &'a &Did, 193 + action: &'a Action, 194 + resource: &'a KnotState, 195 + context: &'a KnotState, 196 + ) -> BoxFuture<'a, PolicyResult> { 197 + async move { 198 + let is_member = subject == resource.owner() 199 + || context 200 + .database() 201 + .is_knot_member(subject) 202 + .await 203 + .is_ok_and(|val| val); 204 + 205 + match (action, is_member) { 206 + (Action::RepositoryCreate, true) => PolicyResult::Granted, 207 + (_, _) => PolicyResult::Denied, 208 + } 209 + } 210 + .boxed() 211 + } 212 + } 213 + 214 + pub struct RepositoryDeletePolicy; 215 + 216 + impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RepositoryDeletePolicy { 217 + fn evaluate_access<'s: 'a, 'a>( 218 + &'s self, 219 + &subject: &'a &Did, 220 + action: &'a Action, 221 + resource: &'a RepositoryRef<'_>, 222 + context: &'a KnotState, 223 + ) -> BoxFuture<'a, PolicyResult> { 224 + async move { 225 + let is_owner = subject == context.owner() || resource.did == subject; 226 + match (action, is_owner) { 227 + (Action::RepositoryDelete, true) => PolicyResult::Granted, 228 + (_, _) => PolicyResult::Denied, 229 + } 230 + } 231 + .boxed() 232 + } 233 + } 234 + 235 + pub struct RepositoryEditPolicy; 236 + 237 + impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RepositoryEditPolicy { 238 + fn evaluate_access<'s: 'a, 'a>( 239 + &'s self, 240 + &subject: &'a &Did, 241 + action: &'a Action, 242 + resource: &'a RepositoryRef<'_>, 243 + _: &'a KnotState, 244 + ) -> BoxFuture<'a, PolicyResult> { 245 + async move { 246 + let is_repository_owner = resource.did == subject; 247 + match (action, is_repository_owner) { 248 + (Action::RepositoryEdit, true) => PolicyResult::Granted, 249 + (_, _) => PolicyResult::Denied, 250 + } 251 + } 252 + .boxed() 253 + } 254 + }
+113
crates/gordian-knot/src/services/seed.rs
··· 1 + use gordian_types::{Did, Tid}; 2 + 3 + use crate::{ 4 + lexicon::{ 5 + com::atproto::repo::list_records::Record, 6 + sh_tangled::{knot::Member, repo::Repo}, 7 + }, 8 + model::Knot, 9 + services::atrepo, 10 + types::RecordKey, 11 + }; 12 + 13 + pub async fn all(knot: &Knot) -> anyhow::Result<()> { 14 + let knot = knot.clone(); 15 + let rev = Tid::MIN.to_string(); 16 + 17 + let did = knot.owner(); 18 + 19 + atrepo::fetch_collection::<_, anyhow::Error>( 20 + knot.resolver(), 21 + knot.http(), 22 + did, 23 + "sh.tangled.knot.member", 24 + { 25 + let knot = knot.clone(); 26 + async move |records| { 27 + for Record { uri, cid, value } in records { 28 + let Ok(member) = serde_json::from_str::<Member>(value.get()) else { 29 + continue; 30 + }; 31 + 32 + knot.add_member(uri.rkey(), &rev, &cid, &member).await?; 33 + } 34 + Ok(()) 35 + } 36 + }, 37 + ) 38 + .await?; 39 + 40 + Ok(()) 41 + } 42 + 43 + pub async fn public_keys(knot: &Knot, did: &Did) -> anyhow::Result<()> { 44 + let did = did.to_owned(); 45 + let rev = Tid::MIN.to_string(); 46 + 47 + atrepo::fetch_collection::<_, anyhow::Error>( 48 + knot.resolver(), 49 + knot.http(), 50 + &did.clone(), 51 + "sh.tangled.publicKey", 52 + { 53 + let knot = knot.clone(); 54 + async move |records| { 55 + for Record { uri, cid, value } in records { 56 + let Ok(public_key) = serde_json::from_str(value.get()) else { 57 + continue; 58 + }; 59 + 60 + knot.database() 61 + .upsert_public_key(&did, uri.rkey(), &rev, &cid, &public_key) 62 + .await?; 63 + 64 + tracing::info!(?did, ?uri, "new public key"); 65 + } 66 + 67 + Ok(()) 68 + } 69 + }, 70 + ) 71 + .await?; 72 + 73 + Ok(()) 74 + } 75 + 76 + pub async fn repositories(knot: &Knot, did: &Did) -> anyhow::Result<()> { 77 + let did = did.to_owned(); 78 + 79 + atrepo::fetch_collection::<_, anyhow::Error>( 80 + knot.resolver(), 81 + knot.http(), 82 + &did.clone(), 83 + "sh.tangled.repo", 84 + { 85 + let knot = knot.clone(); 86 + async move |records| { 87 + for record in records { 88 + let Ok(repo) = serde_json::from_str::<Repo>(record.value.get()) else { 89 + tracing::error!(value = ?record.value, "error parsing record value"); 90 + continue; 91 + }; 92 + 93 + if repo.knot != knot.instance_ident() { 94 + continue; 95 + } 96 + 97 + let rec = RecordKey::try_from(record)?; 98 + if let Err(error) = knot.create_repo(&rec, &repo).await { 99 + tracing::error!(?error, ?repo, "failed to create repository"); 100 + continue; 101 + } 102 + 103 + tracing::info!(?did, uri= ?record.uri, name = %repo.name, "new repository"); 104 + } 105 + 106 + Ok(()) 107 + } 108 + }, 109 + ) 110 + .await?; 111 + 112 + Ok(()) 113 + }
+99
crates/gordian-knot/src/types.rs
··· 1 + use core::fmt; 2 + 3 + use crate::lexicon::com::atproto::repo::list_records::Record; 4 + use gordian_types::{Did, RecordUri}; 5 + 6 + pub mod push_certificate; 7 + pub mod repository_key; 8 + pub mod repository_path; 9 + pub mod sh_tangled; 10 + 11 + #[derive(Debug)] 12 + pub struct RecordKey<'a> { 13 + pub did: &'a Did, 14 + pub collection: &'a str, 15 + pub rkey: &'a str, 16 + pub rev: &'a str, 17 + pub cid: &'a str, 18 + } 19 + 20 + #[derive(Debug)] 21 + pub struct FromRecordError(&'static str); 22 + 23 + impl fmt::Display for FromRecordError { 24 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 + fmt::Display::fmt(&self.0, f) 26 + } 27 + } 28 + 29 + impl From<&'static str> for FromRecordError { 30 + fn from(value: &'static str) -> Self { 31 + Self(value) 32 + } 33 + } 34 + 35 + impl core::error::Error for FromRecordError {} 36 + 37 + impl<'a> TryFrom<&'a Record> for RecordKey<'a> { 38 + type Error = FromRecordError; 39 + fn try_from(value: &'a Record) -> Result<Self, Self::Error> { 40 + let RecordUri { 41 + authority: did, 42 + collection, 43 + rkey, 44 + } = &value.uri; 45 + 46 + Ok(Self { 47 + did, 48 + collection, 49 + rkey, 50 + rev: "2222222222222", 51 + cid: &value.cid, 52 + }) 53 + } 54 + } 55 + 56 + impl<'a> TryFrom<&'a gordian_jetstream::Commit<'a>> for RecordKey<'a> { 57 + type Error = FromRecordError; 58 + 59 + fn try_from(value: &'a gordian_jetstream::Commit<'a>) -> Result<Self, Self::Error> { 60 + let gordian_jetstream::Commit { 61 + ts: _, 62 + did, 63 + collection, 64 + rkey, 65 + rev, 66 + cid, 67 + record: _, 68 + } = value; 69 + 70 + Ok(Self { 71 + did, 72 + collection, 73 + rkey, 74 + rev, 75 + cid, 76 + }) 77 + } 78 + } 79 + 80 + impl<'a> TryFrom<&'a gordian_jetstream::Delete<'a>> for RecordKey<'a> { 81 + type Error = FromRecordError; 82 + fn try_from(value: &'a gordian_jetstream::Delete<'a>) -> Result<Self, Self::Error> { 83 + let gordian_jetstream::Delete { 84 + ts: _, 85 + did, 86 + collection, 87 + rkey, 88 + rev, 89 + } = value; 90 + 91 + Ok(Self { 92 + did, 93 + collection, 94 + rkey, 95 + rev, 96 + cid: "", 97 + }) 98 + } 99 + }
+76
crates/gordian-knot/src/types/repository_key.rs
··· 1 + use core::fmt; 2 + 3 + use gordian_types::OwnedDid; 4 + use serde::Deserialize; 5 + 6 + use super::repository_path::{Error, validate}; 7 + 8 + #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize)] 9 + #[serde(try_from = "UnvalidatedRepositoryKey")] 10 + pub struct RepositoryKey { 11 + /// Repository owner's Did. 12 + pub owner: OwnedDid, 13 + 14 + /// Repository record key. 15 + pub rkey: String, 16 + } 17 + 18 + impl RepositoryKey { 19 + pub fn new(owner: impl Into<OwnedDid>, rkey: impl Into<String>) -> Result<Self, Error> { 20 + fn inner(owner: OwnedDid, rkey: String) -> Result<RepositoryKey, Error> { 21 + validate(&owner)?; 22 + validate(&rkey)?; 23 + 24 + Ok(RepositoryKey { owner, rkey }) 25 + } 26 + inner(owner.into(), rkey.into()) 27 + } 28 + } 29 + 30 + #[derive(Deserialize)] 31 + struct UnvalidatedRepositoryKey { 32 + owner: OwnedDid, 33 + rkey: String, 34 + } 35 + 36 + impl TryFrom<UnvalidatedRepositoryKey> for RepositoryKey { 37 + type Error = Error; 38 + fn try_from(value: UnvalidatedRepositoryKey) -> Result<Self, Self::Error> { 39 + let UnvalidatedRepositoryKey { owner, rkey } = value; 40 + validate(&owner)?; 41 + validate(&rkey)?; 42 + Ok(Self { owner, rkey }) 43 + } 44 + } 45 + 46 + impl RepositoryKey { 47 + pub fn owner_str(&self) -> &str { 48 + &self.owner 49 + } 50 + 51 + pub fn rkey(&self) -> &str { 52 + &self.rkey 53 + } 54 + } 55 + 56 + impl fmt::Display for RepositoryKey { 57 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 + write!(f, "{}/{}", self.owner, self.rkey) 59 + } 60 + } 61 + 62 + impl std::str::FromStr for RepositoryKey { 63 + type Err = Error; 64 + fn from_str(s: &str) -> Result<Self, Self::Err> { 65 + let (owner, name) = s.split_once('/').ok_or(Error::Format)?; 66 + 67 + let owner: OwnedDid = owner.parse().map_err(|_| Error::Format)?; 68 + validate(owner.as_str())?; 69 + validate(name)?; 70 + 71 + Ok(Self { 72 + owner, 73 + rkey: name.into(), 74 + }) 75 + } 76 + }
+213
crates/gordian-knot/src/types/sh_tangled.rs
··· 1 + pub mod repo { 2 + pub mod branches { 3 + use crate::lexicon::sh_tangled::repo::refs; 4 + use serde::Serialize; 5 + 6 + pub use crate::lexicon::sh_tangled::repo::branches::Input; 7 + 8 + /// Output of `sh.tangled.repo.branches` query. 9 + #[derive(Debug, Default, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct Output { 12 + #[serde(skip_serializing_if = "Vec::is_empty")] 13 + pub branches: Vec<Branch>, 14 + } 15 + 16 + #[derive(Debug, Serialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct Branch { 19 + pub reference: refs::Reference, 20 + pub commit: refs::Commit, 21 + #[serde(rename = "is_deafult")] 22 + pub is_default: bool, 23 + } 24 + 25 + pub type Response = axum::Json<Output>; 26 + } 27 + 28 + pub mod compare { 29 + use crate::lexicon::extra::objectid::ObjectId; 30 + use serde::Serialize; 31 + 32 + pub use crate::lexicon::sh_tangled::repo::compare::Input; 33 + 34 + #[derive(Debug, Serialize)] 35 + pub struct Output { 36 + pub rev1: ObjectId, 37 + pub rev2: ObjectId, 38 + #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] 39 + pub format_patch_raw: String, 40 + // 41 + // @NOTE The real knotserver outputs a few more fields here, but the appview 42 + // doesn't use them. I'm going to save myself some effort and just do the 43 + // minimum for now. 44 + } 45 + } 46 + 47 + pub mod diff { 48 + use crate::lexicon::{extra::objectid::ObjectId, sh_tangled::repo::refs}; 49 + use serde::Serialize; 50 + use std::borrow::Cow; 51 + 52 + pub use crate::lexicon::sh_tangled::repo::diff::Input; 53 + 54 + #[derive(Debug, Serialize)] 55 + pub struct Output { 56 + #[serde(rename = "ref")] 57 + pub rev: Box<str>, 58 + pub diff: NiceDiff, 59 + } 60 + 61 + /// Unified diff replicating Tangled's `NiceDiff` structure. 62 + /// 63 + /// See: <https://tangled.org/@tangled.org/core/blob/master/types/diff.go#L44> 64 + #[derive(Debug, Serialize)] 65 + pub struct NiceDiff { 66 + pub commit: Commit, 67 + pub stat: Stat, 68 + #[serde(rename = "diff")] 69 + pub deltas: Vec<Diff>, 70 + } 71 + 72 + #[derive(Debug, Serialize)] 73 + pub struct Commit { 74 + pub message: String, 75 + pub author: refs::Signature, 76 + pub this: ObjectId, 77 + #[serde(skip_serializing_if = "Option::is_none")] 78 + pub parent: Option<ObjectId>, 79 + #[serde(default)] 80 + pub pgp_signature: Cow<'static, str>, 81 + pub committer: refs::Signature, 82 + pub tree: ObjectId, 83 + #[serde(default)] 84 + pub change_id: Cow<'static, str>, 85 + } 86 + 87 + #[derive(Debug, Default, Serialize)] 88 + pub struct Stat { 89 + pub files_changed: u32, 90 + pub insertions: u32, 91 + pub deletions: u32, 92 + } 93 + 94 + #[derive(Debug, Default, Serialize)] 95 + pub struct Diff { 96 + pub name: Name, 97 + pub text_fragments: Vec<TextFragment>, 98 + pub is_binary: bool, 99 + pub is_new: bool, 100 + pub is_delete: bool, 101 + pub is_copy: bool, 102 + pub is_rename: bool, 103 + } 104 + 105 + #[derive(Debug, Default, Serialize)] 106 + pub struct Name { 107 + pub old: String, 108 + pub new: String, 109 + } 110 + 111 + #[derive(Debug, Default, Serialize)] 112 + #[serde(rename_all = "PascalCase")] 113 + pub struct TextFragment { 114 + #[serde(skip_serializing_if = "str::is_empty")] 115 + pub comment: Cow<'static, str>, 116 + pub old_position: u32, 117 + pub old_lines: u32, 118 + pub new_position: u32, 119 + pub new_lines: u32, 120 + pub lines_added: u32, 121 + pub lines_deleted: u32, 122 + pub leading_context: u32, 123 + pub trailing_context: u32, 124 + #[serde(skip_serializing_if = "Vec::is_empty")] 125 + pub lines: Vec<Line>, 126 + } 127 + 128 + #[derive(Debug)] 129 + pub enum Line { 130 + Context { line: String }, 131 + Addition { line: String }, 132 + Deletion { line: String }, 133 + } 134 + 135 + // Manual impl because this enum is tagged with an integer. 136 + impl Serialize for Line { 137 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 138 + where 139 + S: serde::Serializer, 140 + { 141 + #[derive(Serialize)] 142 + #[serde(rename_all = "PascalCase")] 143 + struct IntTagged<'a> { 144 + op: u8, 145 + line: &'a str, 146 + } 147 + 148 + match self { 149 + Self::Context { line } => IntTagged { op: 0, line }, 150 + Self::Addition { line } => IntTagged { op: 1, line }, 151 + Self::Deletion { line } => IntTagged { op: 2, line }, 152 + } 153 + .serialize(serializer) 154 + } 155 + } 156 + } 157 + 158 + pub mod log { 159 + use crate::lexicon::sh_tangled::repo::refs; 160 + use serde::Serialize; 161 + 162 + pub use crate::lexicon::sh_tangled::repo::log::Input; 163 + 164 + #[derive(Debug, Serialize)] 165 + pub struct Output { 166 + pub commits: Vec<refs::Commit>, 167 + pub log: bool, 168 + pub total: usize, 169 + pub page: usize, 170 + pub per_page: u16, 171 + } 172 + } 173 + 174 + pub mod tags { 175 + use crate::lexicon::{ 176 + extra::objectid::{Array, ObjectId}, 177 + sh_tangled::repo::refs, 178 + }; 179 + use serde::Serialize; 180 + 181 + pub use crate::lexicon::sh_tangled::repo::tags::Input; 182 + 183 + /// Output of `sh.tangled.repo.tags` query. 184 + /// 185 + /// This is not defined in the lexicon, but models what knotserver currently 186 + /// produces. 187 + #[derive(Debug, Serialize)] 188 + pub struct Output { 189 + pub tags: Vec<Tag>, 190 + } 191 + 192 + #[derive(Debug, Serialize)] 193 + #[serde(rename_all = "PascalCase")] 194 + pub struct TagAnnotation { 195 + pub hash: ObjectId<Array>, 196 + pub name: String, 197 + pub tagger: Option<refs::Signature>, 198 + pub message: String, 199 + #[serde(rename = "PGPSignature")] 200 + pub pgp_signature: Option<String>, 201 + pub target_type: i32, 202 + pub target: ObjectId<Array>, 203 + } 204 + 205 + #[derive(Debug, Serialize)] 206 + pub struct Tag { 207 + #[serde(flatten)] 208 + pub r#ref: refs::Reference, 209 + #[serde(rename = "tag", skip_serializing_if = "Option::is_none")] 210 + pub annotation: Option<TagAnnotation>, 211 + } 212 + } 213 + }
+20
crates/gordian-lexicon/Cargo.toml
··· 1 + [package] 2 + name = "gordian-lexicon" 3 + version.workspace = true 4 + authors.workspace = true 5 + repository.workspace = true 6 + license.workspace = true 7 + edition.workspace = true 8 + publish.workspace = true 9 + 10 + [dependencies] 11 + gordian-types = { workspace = true, features = ["serde"] } 12 + gordian-identity = { workspace = true } 13 + 14 + data-encoding.workspace = true 15 + serde.workspace = true 16 + serde_json.workspace = true 17 + thiserror.workspace = true 18 + time.workspace = true 19 + 20 + gix-hash = "^0.22.0"
+47
crates/gordian-lexicon/src/com/atproto/repo.rs
··· 1 + pub mod list_records { 2 + //! 3 + //! List a range of records in a repository, matching a specific 4 + //! collection. Does not require auth. 5 + //! 6 + //! <https://docs.bsky.app/docs/api/com-atproto-repo-list-records> 7 + //! 8 + use gordian_types::RecordUri; 9 + 10 + #[derive(Debug, serde::Deserialize, serde::Serialize)] 11 + pub struct Input { 12 + /// The handle or DID of the repo. 13 + pub repo: String, 14 + 15 + /// The NSID of the record type. 16 + pub collection: String, 17 + 18 + /// The number of records to return. 19 + /// 20 + /// Possible values: 0..=100. 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub limit: Option<usize>, 23 + 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + pub cursor: Option<String>, 26 + 27 + /// Flag to reverse the order of the returned records. 28 + #[serde(default)] 29 + pub reverse: bool, 30 + } 31 + 32 + #[derive(Debug, serde::Deserialize, serde::Serialize)] 33 + pub struct Output { 34 + pub cursor: Option<String>, 35 + 36 + pub records: Vec<Record>, 37 + } 38 + 39 + #[derive(Debug, serde::Deserialize, serde::Serialize)] 40 + pub struct Record { 41 + pub uri: RecordUri, 42 + 43 + pub cid: String, 44 + 45 + pub value: Box<serde_json::value::RawValue>, 46 + } 47 + }
+121
crates/gordian-lexicon/src/sh_tangled.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/tree/master/lexicons> 3 + //! 4 + pub mod actor; 5 + pub mod feed; 6 + pub mod git; 7 + pub mod graph; 8 + pub mod knot; 9 + pub mod repo; 10 + pub mod spindle; 11 + pub mod string; 12 + 13 + use gordian_types::Did; 14 + use serde::{Deserialize, Serialize}; 15 + use std::borrow::Cow; 16 + use time::OffsetDateTime; 17 + 18 + pub mod owner { 19 + use gordian_types::Did; 20 + use serde::{Deserialize, Serialize}; 21 + 22 + /// XRPC query `sh.tangled.owner` output. 23 + /// 24 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/owner.json> 25 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 26 + pub struct Output<'a> { 27 + #[serde(borrow)] 28 + pub owner: &'a Did, 29 + } 30 + } 31 + 32 + /// `sh.tangled.publicKey` record. 33 + /// 34 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/publicKey.json> 35 + #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 36 + #[serde(rename_all = "camelCase")] 37 + pub struct PublicKey<'a> { 38 + /// Public key contents 39 + #[serde(borrow)] 40 + pub key: Cow<'a, str>, 41 + 42 + /// Human-readable name for this key 43 + pub name: Cow<'a, str>, 44 + 45 + /// Key upload timestamp 46 + #[serde(alias = "created", with = "time::serde::rfc3339")] 47 + pub created_at: OffsetDateTime, 48 + } 49 + 50 + /// `sh.tangled.publicKey` record. 51 + /// 52 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/publicKey.json> 53 + #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 54 + #[serde(rename_all = "camelCase")] 55 + pub struct PublicKeyOwned { 56 + /// Public key contents 57 + pub key: String, 58 + 59 + /// Human-readable name for this key 60 + pub name: String, 61 + 62 + /// Key upload timestamp 63 + #[serde(alias = "created", with = "time::serde::rfc3339")] 64 + pub created_at: OffsetDateTime, 65 + } 66 + 67 + impl<'a> From<&'a PublicKeyOwned> for PublicKey<'a> { 68 + fn from(value: &'a PublicKeyOwned) -> Self { 69 + Self { 70 + key: Cow::Borrowed(&value.key), 71 + name: Cow::Borrowed(&value.name), 72 + created_at: value.created_at, 73 + } 74 + } 75 + } 76 + 77 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 78 + #[serde(rename_all = "camelCase")] 79 + pub struct Pipeline<'a> { 80 + trigger_metadata: TriggerMetadata<'a>, 81 + } 82 + 83 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 84 + #[serde(rename_all = "camelCase")] 85 + pub struct TriggerMetadata<'a> { 86 + pub kind: Cow<'a, str>, 87 + } 88 + 89 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 90 + #[serde(rename_all = "camelCase")] 91 + pub struct TriggerRepo<'a> { 92 + #[serde(borrow)] 93 + pub knot: Cow<'a, str>, 94 + 95 + pub did: Cow<'a, Did>, 96 + 97 + pub repo: Cow<'a, str>, 98 + 99 + pub default_branch: Cow<'a, str>, 100 + } 101 + 102 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 103 + #[serde(rename_all = "camelCase")] 104 + pub struct Workflow<'a> { 105 + #[serde(borrow)] 106 + pub name: Cow<'a, str>, 107 + 108 + pub engine: Cow<'a, str>, 109 + 110 + pub clone: CloneOptions, 111 + 112 + pub raw: Cow<'a, str>, 113 + } 114 + 115 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 116 + #[serde(rename_all = "camelCase")] 117 + pub struct CloneOptions { 118 + pub skip: bool, 119 + pub depth: u64, 120 + pub submodules: bool, 121 + }
+35
crates/gordian-lexicon/src/sh_tangled/feed.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/feed> 3 + //! 4 + use gordian_types::RecordUri; 5 + use serde::{Deserialize, Serialize}; 6 + use std::borrow::Cow; 7 + use time::OffsetDateTime; 8 + 9 + /// `sh.tangled.feed.reaction` record. 10 + /// 11 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/feed/reaction.json> 12 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct Reaction<'a> { 15 + #[serde(alias = "created", with = "time::serde::rfc3339")] 16 + pub created_at: OffsetDateTime, 17 + 18 + #[serde(borrow)] 19 + pub reaction: Cow<'a, str>, 20 + 21 + pub subject: Cow<'a, RecordUri>, 22 + } 23 + 24 + /// `sh.tangled.feed.star` record. 25 + /// 26 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/feed/star.json> 27 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 28 + #[serde(rename_all = "camelCase")] 29 + pub struct Star { 30 + #[serde(alias = "created", with = "time::serde::rfc3339")] 31 + pub created_at: OffsetDateTime, 32 + 33 + // @TODO This should be an at-uri. 34 + pub subject: RecordUri, 35 + }
+108
crates/gordian-lexicon/src/sh_tangled/git.rs
··· 1 + use gordian_types::OwnedDid; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + use crate::extra::objectid::ObjectId; 5 + 6 + /// `sh.tangled.git.refUpdate` record 7 + /// 8 + /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/git/refUpdate.json> 9 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct RefUpdate { 12 + /// Ref being updated. 13 + pub r#ref: String, 14 + 15 + /// DID of the user that push this ref. 16 + pub committer_did: OwnedDid, 17 + 18 + /// DID of the owner of the repo. 19 + pub repo_did: OwnedDid, 20 + 21 + /// Name of the repo. 22 + pub repo_name: String, 23 + 24 + /// Old SHA of this ref. 25 + pub old_sha: ObjectId, 26 + 27 + /// New SHA of this ref. 28 + pub new_sha: ObjectId, 29 + 30 + pub meta: Meta, 31 + } 32 + 33 + #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 34 + #[serde(rename_all = "camelCase")] 35 + pub struct Meta { 36 + pub is_default_ref: bool, 37 + 38 + pub lang_breakdown: LanguageBreakdown, 39 + 40 + pub commit_count: CommitCountBreakdown, 41 + } 42 + 43 + #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 44 + #[serde(rename_all = "camelCase")] 45 + pub struct LanguageBreakdown { 46 + #[serde(default)] 47 + pub inputs: Vec<Language>, 48 + } 49 + 50 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 51 + #[serde(rename_all = "camelCase")] 52 + pub struct Language { 53 + pub lang: String, 54 + pub size: u64, 55 + } 56 + 57 + #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 58 + #[serde(rename_all = "camelCase")] 59 + pub struct CommitCountBreakdown { 60 + #[serde(default)] 61 + pub by_email: Vec<CommitCount>, 62 + } 63 + 64 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 65 + #[serde(rename_all = "camelCase")] 66 + pub struct CommitCount { 67 + pub email: String, 68 + pub count: u64, 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::RefUpdate; 74 + 75 + #[test] 76 + fn can_deserialize() { 77 + const SAMPLE: &str = r#" 78 + { 79 + "$type": "", 80 + "committerDid": "did:plc:65gha4t3avpfpzmvpbwovss7", 81 + "meta": { 82 + "commitCount": { 83 + "byEmail": [ 84 + { 85 + "count": 1, 86 + "email": "x@tjh.dev" 87 + } 88 + ] 89 + }, 90 + "isDefaultRef": true, 91 + "langBreakdown": {} 92 + }, 93 + "newSha": "74771f21d2d409e7014af3206839fda30a429311", 94 + "oldSha": "0000000000000000000000000000000000000000", 95 + "ref": "refs/heads/main", 96 + "repoDid": "did:plc:65gha4t3avpfpzmvpbwovss7", 97 + "repoName": "lol" 98 + } 99 + 100 + "#; 101 + 102 + let update: RefUpdate = serde_json::from_str(SAMPLE).unwrap(); 103 + assert_eq!( 104 + update.committer_did.as_str(), 105 + "did:plc:65gha4t3avpfpzmvpbwovss7" 106 + ); 107 + } 108 + }
+51
crates/gordian-lexicon/src/sh_tangled/graph.rs
··· 1 + //! 2 + //! <https://tangled.org/tangled.org/core/tree/master/lexicons/graph> 3 + //! 4 + use std::borrow::Cow; 5 + 6 + /// `sh.tangled.graph.follow` record. 7 + /// 8 + /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/graph/follow.json> 9 + /// 10 + #[derive(Debug, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 11 + #[serde(rename_all = "camelCase")] 12 + pub struct Follow<'a> { 13 + #[serde(borrow, with = "gordian_types::serde::cow_did")] 14 + pub subject: Cow<'a, gordian_types::Did>, 15 + 16 + #[serde(with = "time::serde::rfc3339")] 17 + pub created_at: time::OffsetDateTime, 18 + } 19 + 20 + #[test] 21 + fn can_deserialize_follow() -> Result<(), serde_json::Error> { 22 + #[derive(serde::Deserialize)] 23 + #[serde(tag = "$type")] 24 + #[allow(unused)] 25 + enum Lexicon<'a> { 26 + #[serde(rename = "sh.tangled.graph.follow")] 27 + Follow(#[serde(borrow)] Follow<'a>), 28 + } 29 + 30 + fn check(s: &str) -> Result<Lexicon<'_>, serde_json::Error> { 31 + serde_json::from_str(s) 32 + } 33 + 34 + check( 35 + r#"{ 36 + "$type": "sh.tangled.graph.follow", 37 + "subject": "did:plc:wshs7t2adsemcrrd4snkeqli", 38 + "createdAt":"2025-04-17T12:37:42Z" 39 + }"#, 40 + )?; 41 + 42 + check( 43 + r#"{ 44 + "$type": "sh.tangled.graph.follow", 45 + "subject": "did:plc:xasnlahkri4ewmbuzly2rlc5", 46 + "createdAt": "2026-01-16T14:39:26+02:00" 47 + }"#, 48 + )?; 49 + 50 + Ok(()) 51 + }
+53
crates/gordian-lexicon/src/sh_tangled/knot.rs
··· 1 + use gordian_types::Did; 2 + use serde::{Deserialize, Serialize}; 3 + use std::borrow::Cow; 4 + use time::OffsetDateTime; 5 + 6 + pub mod version { 7 + use serde::{Deserialize, Serialize}; 8 + use std::borrow::Cow; 9 + 10 + /// XRPC query `sh.tangled.knot.version` output. 11 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct Output { 14 + #[serde(borrow)] 15 + pub version: Cow<'static, str>, 16 + } 17 + } 18 + 19 + /// `sh.tangled.knot` record. 20 + /// 21 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/knot/knot.json> 22 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 23 + #[serde(rename = "sh.tangled.knot", rename_all = "camelCase")] 24 + pub struct Knot { 25 + #[serde(with = "time::serde::rfc3339")] 26 + pub created_at: OffsetDateTime, 27 + } 28 + 29 + /// `sh.tangled.knot.member` record. 30 + /// 31 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/knot/member.json> 32 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 33 + #[serde(rename = "sh.tangled.knot.member", rename_all = "camelCase")] 34 + pub struct Member<'a> { 35 + #[serde(borrow)] 36 + pub subject: Cow<'a, Did>, 37 + 38 + /// Domain that this member now belongs to 39 + pub domain: Cow<'a, str>, 40 + 41 + #[serde(with = "time::serde::rfc3339")] 42 + pub created_at: OffsetDateTime, 43 + } 44 + 45 + impl<'a> Member<'a> { 46 + pub fn new(subject: &'a Did, domain: &'a str, created_at: OffsetDateTime) -> Self { 47 + Self { 48 + subject: subject.into(), 49 + domain: domain.into(), 50 + created_at, 51 + } 52 + } 53 + }
+197
crates/gordian-lexicon/src/sh_tangled/repo.rs
··· 1 + pub mod archive; 2 + pub mod blob; 3 + pub mod branch; 4 + pub mod branches; 5 + pub mod compare; 6 + pub mod create; 7 + pub mod delete; 8 + pub mod diff; 9 + pub mod get_default_branch; 10 + pub mod issue; 11 + pub mod languages; 12 + pub mod log; 13 + pub mod merge_check; 14 + pub mod pull; 15 + pub mod set_default_branch; 16 + pub mod tags; 17 + pub mod tree; 18 + 19 + use gordian_types::{Did, OwnedDid, RecordUri}; 20 + use serde::{Deserialize, Serialize}; 21 + use std::borrow::Cow; 22 + use time::OffsetDateTime; 23 + 24 + use crate::extra::objectid::ObjectId; 25 + 26 + /// `sh.tangled.repo.issue` record. 27 + /// 28 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/collaborator.json> 29 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 30 + #[serde(rename = "sh.tangled.repo.collaborator", rename_all = "camelCase")] 31 + pub struct Collaborator<'a> { 32 + #[serde(borrow)] 33 + pub subject: &'a Did, 34 + 35 + /// Domain that this member now belongs to 36 + pub repo: RecordUri, 37 + 38 + #[serde(with = "time::serde::rfc3339")] 39 + pub created_at: OffsetDateTime, 40 + } 41 + 42 + /// `sh.tangled.repo.issue` record. 43 + /// 44 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/issue.json> 45 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 46 + #[serde(rename_all = "camelCase")] 47 + pub struct Issue<'a> { 48 + pub repo: RecordUri, 49 + 50 + #[serde(borrow)] 51 + pub title: Cow<'a, str>, 52 + 53 + #[serde(with = "time::serde::rfc3339")] 54 + pub created_at: OffsetDateTime, 55 + 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub body: Option<Cow<'a, str>>, 58 + 59 + #[serde(skip_serializing_if = "Vec::is_empty")] 60 + pub mentions: Vec<OwnedDid>, 61 + 62 + #[serde(skip_serializing_if = "Vec::is_empty")] 63 + pub references: Vec<RecordUri>, 64 + } 65 + 66 + /// `sh.tangled.repo` record. 67 + /// 68 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/repo.json> 69 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 70 + #[serde(rename_all = "camelCase")] 71 + pub struct Repo<'a> { 72 + /// Name of the repo 73 + #[serde(borrow)] 74 + pub name: Cow<'a, str>, 75 + 76 + /// Knot where the repo was created 77 + pub knot: Cow<'a, str>, 78 + 79 + /// CI runner to send jobs to and receive results from 80 + #[serde(skip_serializing_if = "Option::is_none")] 81 + pub spindle: Option<Cow<'a, str>>, 82 + 83 + #[serde(skip_serializing_if = "Option::is_none")] 84 + pub description: Option<Cow<'a, str>>, 85 + 86 + pub website: Option<Cow<'a, str>>, 87 + 88 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 89 + pub topics: Vec<Cow<'a, str>>, 90 + 91 + /// Source of the repo 92 + #[serde(skip_serializing_if = "Option::is_none")] 93 + pub source: Option<Cow<'a, str>>, 94 + 95 + /// List of labels that this repo subscribes to 96 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 97 + pub labels: Vec<RecordUri>, 98 + 99 + #[serde(with = "time::serde::rfc3339")] 100 + #[serde(alias = "addedAt")] 101 + pub created_at: OffsetDateTime, 102 + } 103 + 104 + /// `sh.tangled.repo.pull` record. 105 + /// 106 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/pulls/pull.json> 107 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 108 + #[serde(rename = "sh.tangled.repo.pull", rename_all = "camelCase")] 109 + pub struct Pull<'a> { 110 + #[serde(borrow)] 111 + pub target: PullTarget<'a>, 112 + 113 + pub title: Cow<'a, str>, 114 + 115 + #[serde(skip_serializing_if = "Option::is_none")] 116 + pub body: Option<Cow<'a, str>>, 117 + 118 + pub patch: Cow<'a, str>, 119 + 120 + #[serde(skip_serializing_if = "Option::is_none")] 121 + pub source: Option<PullSource<'a>>, 122 + 123 + #[serde(with = "time::serde::rfc3339")] 124 + pub created_at: OffsetDateTime, 125 + 126 + #[serde(skip_serializing_if = "Vec::is_empty")] 127 + pub mentions: Vec<OwnedDid>, 128 + 129 + #[serde(skip_serializing_if = "Vec::is_empty")] 130 + pub references: Vec<RecordUri>, 131 + } 132 + 133 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 134 + pub struct PullTarget<'a> { 135 + pub repo: RecordUri, 136 + 137 + #[serde(borrow)] 138 + pub branch: Cow<'a, str>, 139 + } 140 + 141 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 142 + pub struct PullSource<'a> { 143 + #[serde(borrow)] 144 + pub branch: Cow<'a, str>, 145 + 146 + pub sha: ObjectId, 147 + 148 + #[serde(skip_serializing_if = "Option::is_none")] 149 + pub repo: Option<RecordUri>, 150 + } 151 + 152 + /// Lexicon sub-types. 153 + pub mod refs { 154 + use crate::extra::objectid::{Array, ObjectId}; 155 + use serde::Serialize; 156 + use std::{borrow::Cow, collections::HashMap}; 157 + use time::OffsetDateTime; 158 + 159 + #[derive(Debug, Default, Serialize)] 160 + #[serde(rename_all = "camelCase")] 161 + pub struct Reference { 162 + /// Short-name of the reference. 163 + pub name: String, 164 + pub hash: ObjectId, 165 + } 166 + 167 + /// Git commit signature (ie. the author or committer). 168 + #[derive(Debug, Serialize)] 169 + pub struct Signature { 170 + /// Author or committer name. 171 + pub name: String, 172 + 173 + /// Author or committer email. 174 + pub email: String, 175 + 176 + /// Author or committer timestamp. 177 + #[serde(with = "time::serde::rfc3339")] 178 + pub when: OffsetDateTime, 179 + } 180 + 181 + #[derive(Debug, Serialize)] 182 + #[serde(rename_all = "PascalCase")] 183 + pub struct Commit { 184 + pub hash: ObjectId<Array>, 185 + pub author: Signature, 186 + pub committer: Signature, 187 + pub merge_tag: String, 188 + #[serde(rename = "PGPSignature")] 189 + pub pgp_signature: Option<String>, 190 + pub message: String, 191 + pub tree_hash: ObjectId<Array>, 192 + pub parent_hashes: Vec<ObjectId<Array>>, 193 + pub encoding: Cow<'static, str>, 194 + /// Non-standard object headers. Values are always base64 encoded. 195 + pub extra_headers: HashMap<String, String>, 196 + } 197 + }
+38
crates/gordian-lexicon/src/sh_tangled/repo/delete.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 3 + //! 4 + 5 + use gordian_types::OwnedDid; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// Parameters for the `sh.tangled.repo.delete` procedure. 9 + /// 10 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 11 + #[derive(Debug, Deserialize, Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct Input { 14 + /// DID of the repository owner. 15 + pub did: OwnedDid, 16 + 17 + /// Record key of the repository record. 18 + pub rkey: String, 19 + 20 + /// Name of the repository to delete. 21 + pub name: String, 22 + } 23 + 24 + #[cfg(test)] 25 + mod tests { 26 + use super::Input; 27 + 28 + #[test] 29 + fn can_deserialize_required() { 30 + const REQUEST: &str = 31 + r#"{"did":"did:plc:65gha4t3avpfpzmvpbwovss7","rkey":"3m24udbjajf22","name":"gordian"}"#; 32 + 33 + let input: Input = serde_json::from_str(REQUEST).expect("should deserialize"); 34 + assert_eq!(input.did.as_str(), "did:plc:65gha4t3avpfpzmvpbwovss7"); 35 + assert_eq!(input.rkey, "3m24udbjajf22"); 36 + assert_eq!(input.name, "gordian"); 37 + } 38 + }
+44
crates/gordian-lexicon/src/sh_tangled/repo/issue.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/issue> 3 + //! 4 + use gordian_types::{OwnedDid, RecordUri}; 5 + use serde::{Deserialize, Serialize}; 6 + use std::borrow::Cow; 7 + use time::OffsetDateTime; 8 + 9 + /// `sh.tangled.repo.issue.comment` record. 10 + /// 11 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/comment.json> 12 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 13 + #[serde(rename_all = "camelCase")] 14 + pub struct Comment<'a> { 15 + #[serde(borrow)] 16 + pub issue: Cow<'a, str>, 17 + 18 + pub body: Cow<'a, str>, 19 + 20 + #[serde(with = "time::serde::rfc3339")] 21 + pub created_at: OffsetDateTime, 22 + 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + pub reply_to: Option<RecordUri>, 25 + 26 + #[serde(skip_serializing_if = "Vec::is_empty")] 27 + pub mentions: Vec<OwnedDid>, 28 + 29 + #[serde(skip_serializing_if = "Vec::is_empty")] 30 + pub references: Vec<RecordUri>, 31 + } 32 + 33 + /// `sh.tangled.repo.issue.state` record. 34 + /// 35 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/state.json> 36 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 37 + #[serde(rename_all = "camelCase")] 38 + pub struct State<'a> { 39 + pub issue: RecordUri, 40 + 41 + /// State of the issue. 42 + #[serde(borrow)] 43 + pub state: Cow<'a, str>, 44 + }
+52
crates/gordian-lexicon/src/sh_tangled/repo/merge_check.rs
··· 1 + //! 2 + //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/mergeCheck.json> 3 + //! 4 + use std::borrow::Cow; 5 + 6 + use gordian_types::OwnedDid; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// Parameters for the `sh.tangled.repo.mergeCheck` query. 10 + /// 11 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/mergeCheck.json> 12 + #[derive(Debug, Deserialize)] 13 + pub struct Input { 14 + /// DID of the repository owner 15 + pub did: OwnedDid, 16 + 17 + /// Name of the repository 18 + pub name: String, 19 + 20 + /// Patch or pull request to check for merge conflicts 21 + pub patch: String, 22 + 23 + /// Target branch to merge into 24 + pub branch: String, 25 + } 26 + 27 + #[derive(Debug, Serialize)] 28 + pub struct Output { 29 + /// Whether the merge as conflicts 30 + pub is_conflicted: bool, 31 + 32 + /// List of files with merge conflicts 33 + #[serde(skip_serializing_if = "Vec::is_empty")] 34 + pub conflicts: Vec<ConflictInfo>, 35 + 36 + /// Additional message about the merge check 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub message: Option<Cow<'static, str>>, 39 + 40 + /// Error message if check failed 41 + #[serde(skip_serializing_if = "Option::is_none")] 42 + pub error: Option<Cow<'static, str>>, 43 + } 44 + 45 + #[derive(Debug, Serialize)] 46 + pub struct ConflictInfo { 47 + /// Name of the conflicted file 48 + pub filename: String, 49 + 50 + /// Reason for the conflict 51 + pub reason: Cow<'static, str>, 52 + }
+38
crates/gordian-lexicon/src/sh_tangled/repo/pull.rs
··· 1 + //! 2 + //! <https://tangled.org/tangled.org/core/tree/master/lexicons/pulls> 3 + //! 4 + use std::borrow::Cow; 5 + 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// `sh.tangled.repo.pull.comment` record. 9 + /// 10 + /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/comment.json> 11 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct Comment<'a> { 14 + pub pull: gordian_types::RecordUri, 15 + 16 + #[serde(borrow)] 17 + pub body: Cow<'a, str>, 18 + 19 + #[serde(with = "time::serde::rfc3339")] 20 + pub created_at: time::OffsetDateTime, 21 + 22 + #[serde(skip_serializing_if = "Vec::is_empty")] 23 + pub mentions: Vec<gordian_types::OwnedDid>, 24 + 25 + #[serde(skip_serializing_if = "Vec::is_empty")] 26 + pub references: Vec<gordian_types::RecordUri>, 27 + } 28 + 29 + /// `sh.tangled.repo.pull.state` record. 30 + /// 31 + /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/state.json> 32 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 33 + pub struct Status<'a> { 34 + pub pull: gordian_types::RecordUri, 35 + 36 + #[serde(borrow)] 37 + pub status: Cow<'a, str>, 38 + }
+14
crates/gordian-lexicon/src/sh_tangled/repo/set_default_branch.rs
··· 1 + use gordian_types::RecordUri; 2 + use serde::Deserialize; 3 + 4 + /// Parameters for the `sh.tangled.repo.setDefaultBranch` procedure. 5 + /// 6 + /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/defaultBranch.json> 7 + /// 8 + #[derive(Debug, Deserialize)] 9 + #[serde(rename_all = "camelCase")] 10 + pub struct Input { 11 + pub repo: RecordUri, 12 + 13 + pub default_branch: String, 14 + }
+30
crates/gordian-lexicon/src/sh_tangled/spindle.rs
··· 1 + use gordian_types::Did; 2 + use serde::{Deserialize, Serialize}; 3 + use std::borrow::Cow; 4 + use time::OffsetDateTime; 5 + 6 + /// `sh.tangled.spindle` record. 7 + /// 8 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/spindle/spindle.json> 9 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct Spindle { 12 + #[serde(with = "time::serde::rfc3339")] 13 + pub created_at: OffsetDateTime, 14 + } 15 + 16 + /// `sh.tangled.spindle.member` record. 17 + /// 18 + /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/spindle/member.json> 19 + #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 20 + #[serde(rename_all = "camelCase")] 21 + pub struct Member<'a> { 22 + #[serde(borrow, with = "gordian_types::serde::cow_did")] 23 + pub subject: Cow<'a, Did>, 24 + 25 + /// Spindle instance that the subject is now a member of. 26 + pub instance: Cow<'a, str>, 27 + 28 + #[serde(with = "time::serde::rfc3339")] 29 + pub created_at: OffsetDateTime, 30 + }
+19
crates/gordian-types/Cargo.toml
··· 1 + [package] 2 + name = "gordian-types" 3 + version.workspace = true 4 + authors.workspace = true 5 + repository.workspace = true 6 + license.workspace = true 7 + edition.workspace = true 8 + publish.workspace = true 9 + 10 + [dependencies] 11 + smallstr = { version = "0.3.1" } 12 + thiserror.workspace = true 13 + 14 + serde = { workspace = true, optional = true } 15 + sqlx = { version = "0.8.6", optional = true } 16 + time = { workspace = true, optional = true } 17 + 18 + [dev-dependencies] 19 + serde_json.workspace = true
-29
crates/identity/Cargo.toml
··· 1 - [package] 2 - name = "identity" 3 - version.workspace = true 4 - authors.workspace = true 5 - repository.workspace = true 6 - license.workspace = true 7 - edition.workspace = true 8 - publish.workspace = true 9 - 10 - [dependencies] 11 - atproto = { workspace = true, features = ["serde"] } 12 - 13 - reqwest.workspace = true 14 - serde.workspace = true 15 - serde_json.workspace = true 16 - thiserror.workspace = true 17 - tracing.workspace = true 18 - url.workspace = true 19 - 20 - futures-util = "0.3.31" 21 - hickory-resolver = "0.25.2" 22 - moka = { version = "0.12.11", features = ["future"] } 23 - tokio = { version = "1.47.1", default-features = false, features = ["macros"] } 24 - tracing-subscriber = { version = "0.3.20", optional = true } 25 - 26 - [[bin]] 27 - name = "resolve" 28 - path = "src/bin/resolve.rs" 29 - required-features = ["tracing-subscriber", "tokio/rt"]
crates/identity/src/bin/resolve.rs crates/gordian-identity/src/bin/resolve.rs
-85
crates/identity/src/document.rs
··· 1 - use atproto::did::OwnedDid; 2 - use serde::{Deserialize, Serialize}; 3 - use url::Url; 4 - 5 - #[derive(Clone, Debug, Deserialize, Serialize)] 6 - #[serde(tag = "type", rename_all = "PascalCase")] 7 - pub enum VerificationMethod { 8 - #[serde(rename_all = "camelCase")] 9 - Multikey { 10 - id: String, 11 - controller: OwnedDid, 12 - public_key_multibase: String, 13 - }, 14 - } 15 - 16 - #[derive(Clone, Debug, Deserialize, Serialize)] 17 - #[serde(rename_all = "camelCase")] 18 - pub struct Service { 19 - pub id: String, 20 - #[serde(rename = "type")] 21 - pub typ: String, 22 - pub service_endpoint: Url, 23 - } 24 - 25 - impl Service { 26 - /// Create a [`Service`] definition for an `ATproto` PDS from `service_endpoint`. 27 - #[must_use] 28 - pub fn atproto_pds(service_endpoint: Url) -> Self { 29 - Self { 30 - id: "#atproto_pds".to_string(), 31 - typ: "AtprotoPersonalDataServer".to_string(), 32 - service_endpoint, 33 - } 34 - } 35 - } 36 - 37 - #[derive(Clone, Debug, Deserialize, Serialize)] 38 - #[serde(rename_all = "camelCase")] 39 - pub struct DidDocument { 40 - #[serde(default, rename = "@context")] 41 - pub context: Vec<Url>, 42 - pub id: OwnedDid, 43 - pub also_known_as: Vec<Url>, 44 - pub verification_method: Vec<VerificationMethod>, 45 - pub service: Vec<Service>, 46 - } 47 - 48 - impl DidDocument { 49 - pub fn new(id: &str, handle: &str) -> Result<Self, atproto::did::Error> { 50 - let id = id.parse()?; 51 - Ok(Self { 52 - context: Default::default(), 53 - id, 54 - also_known_as: vec![Url::parse(&format!("at://{handle}")).expect("valid handle")], 55 - verification_method: Default::default(), 56 - service: Default::default(), 57 - }) 58 - } 59 - 60 - #[must_use] 61 - pub fn primary_alias(&self) -> Option<&str> { 62 - self.also_known_as 63 - .first() 64 - .and_then(|at_uri| at_uri.domain()) 65 - } 66 - 67 - #[must_use] 68 - pub fn atproto_pds(&self) -> Option<&Service> { 69 - self.service.iter().find(|service| { 70 - service.id == "#atproto_pds" && service.typ == "AtprotoPersonalDataServer" 71 - }) 72 - } 73 - } 74 - 75 - #[cfg(test)] 76 - mod tests { 77 - use super::DidDocument; 78 - 79 - #[test] 80 - fn init_doc() { 81 - let doc = DidDocument::new("did:plc:65gha4t3avpfpzmvpbwovss7", "tjh.dev") 82 - .expect("valid did document"); 83 - assert_eq!(doc.primary_alias(), Some("tjh.dev")); 84 - } 85 - }
-326
crates/identity/src/lib.rs
··· 1 - mod document; 2 - pub mod resolvers; 3 - 4 - use core::fmt; 5 - use std::sync::Arc; 6 - 7 - use atproto::did::OwnedDid; 8 - use futures_util::{FutureExt as _, future::BoxFuture}; 9 - 10 - pub use atproto::did::Did; 11 - pub use document::{DidDocument, Service, VerificationMethod}; 12 - 13 - use crate::resolvers::mock::MockResolver; 14 - 15 - pub const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory"; 16 - 17 - pub type HttpClient = reqwest::Client; 18 - pub type HttpError = reqwest::Error; 19 - 20 - pub trait ResolveIdentity: fmt::Debug + Sync { 21 - /// Resolve a handle or a DID to a DID and DID document. 22 - /// 23 - fn resolve<'s: 'a, 'a>( 24 - &'s self, 25 - ident: &'a str, 26 - ) -> BoxFuture<'a, Result<(OwnedDid, DidDocument), ResolveError>> { 27 - if let Ok(did) = ident.parse::<OwnedDid>() { 28 - async { 29 - let doc = self.resolve_did(&did).await?; 30 - let handle = doc.primary_alias().ok_or(ResolveError::InvalidDocument)?; 31 - 32 - // Verify the primary handle in the DID document resolves to 33 - // the DID we were given. 34 - let resolved = self.resolve_handle(handle).await?; 35 - if did == resolved { Ok((did, doc)) } else { Err(ResolveError::BidirectionalFailure) } 36 - } 37 - .boxed() 38 - } else { 39 - let handle = ident.trim_start_matches('@'); 40 - async move { 41 - let did = self.resolve_handle(handle).await?; 42 - let doc = self.resolve_did(&did).await?; 43 - 44 - // Verify the document has a matching handle. 45 - for alias in &doc.also_known_as { 46 - if alias.domain().is_some_and(|host| host == ident) { 47 - return Ok((did, doc)); 48 - } 49 - } 50 - 51 - Err(ResolveError::BidirectionalFailure) 52 - } 53 - .boxed() 54 - } 55 - } 56 - 57 - /// Resolve a handle to a DID. 58 - /// 59 - /// Implementors are not required to bi-directionally confirm the resolution. 60 - /// 61 - /// [`ResolveIdentity::resolve`] should be preferred. 62 - /// 63 - /// Related: <https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle> 64 - fn resolve_handle<'s: 'h, 'h>( 65 - &'s self, 66 - handle: &'h str, 67 - ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>>; 68 - 69 - /// Resolve a DID to DID document. 70 - /// 71 - /// Implementors are not required to bi-directionally confirm the resolution. 72 - /// 73 - /// [`ResolveIdentity::resolve`] should be preferred. 74 - /// 75 - /// Ref: <https://docs.bsky.app/docs/api/com-atproto-identity-resolve-did> 76 - fn resolve_did<'s: 'd, 'd>( 77 - &'s self, 78 - did: &'d Did, 79 - ) -> BoxFuture<'d, Result<DidDocument, ResolveError>>; 80 - 81 - /// Instruct a caching resolver to remove any cached resolutions for the 82 - /// specified DID. 83 - /// 84 - /// This will have no effect on system-level caches, like DNS caches. 85 - /// 86 - fn invalidate_did<'s: 'd, 'd>(&'s self, _: &'d Did) -> BoxFuture<'d, ()> { 87 - async {}.boxed() 88 - } 89 - } 90 - 91 - #[derive(Clone, Debug, thiserror::Error)] 92 - pub enum ResolveError { 93 - #[error("Failed to resolve handle")] 94 - UnresolvedHandle, 95 - #[error("DID method not supported: {0}")] 96 - UnsupportedDidMethod(String), 97 - #[error("Failed to resolve DID document: {0}")] 98 - Document(Arc<HttpError>), 99 - #[error("Bidirectional resolution failed")] 100 - BidirectionalFailure, 101 - #[error("Handle is invalid")] 102 - InvalidHandle, 103 - #[error("DID document is invalid")] 104 - InvalidDocument, 105 - } 106 - 107 - impl From<HttpError> for ResolveError { 108 - fn from(value: HttpError) -> Self { 109 - Self::Document(Arc::new(value)) 110 - } 111 - } 112 - 113 - #[derive(Clone, Debug)] 114 - pub struct Resolver { 115 - inner: std::sync::Arc<dyn ResolveIdentity + Sync + Send + 'static>, 116 - } 117 - 118 - impl Resolver { 119 - pub fn new<R>(resolver: R) -> Self 120 - where 121 - R: ResolveIdentity + Send + 'static, 122 - { 123 - let inner = Arc::new(resolver); 124 - Self { inner } 125 - } 126 - 127 - #[must_use] 128 - pub fn builder() -> ResolverBuilder { 129 - ResolverBuilder::new() 130 - } 131 - 132 - /// Resolve a handle or a DID to a DID document. 133 - pub async fn resolve(&self, ident: &str) -> Result<(OwnedDid, DidDocument), ResolveError> { 134 - let ident = ident.trim_start_matches('@'); 135 - self.inner.resolve(ident).await 136 - } 137 - 138 - /// Resolve a handle to a DID. 139 - #[inline] 140 - pub async fn resolve_handle(&self, handle: &str) -> Result<OwnedDid, ResolveError> { 141 - let handle = handle.trim_start_matches('@'); 142 - self.inner.resolve_handle(handle).await 143 - } 144 - 145 - /// Resolve a DID to a DID document. 146 - #[inline] 147 - pub async fn resolve_did(&self, did: &Did) -> Result<DidDocument, ResolveError> { 148 - self.inner.resolve_did(did).await 149 - } 150 - 151 - #[inline] 152 - pub async fn invalidate_did(&self, did: &Did) { 153 - self.inner.invalidate_did(did).await; 154 - } 155 - } 156 - 157 - impl Resolver { 158 - /// Create a mock resolver, which only resolves the specified identities from 159 - /// memory. 160 - /// 161 - /// The underlying [`MockResolver`] is returned along with the type-erased [`Resolver`] to 162 - /// enable access to [`MockResolver::insert`]. 163 - /// 164 - pub fn mocked(documents: impl IntoIterator<Item = DidDocument>) -> (Self, MockResolver) { 165 - let resolver = MockResolver::new(documents); 166 - let mocked = resolver.clone(); 167 - let inner = Arc::new(resolver); 168 - (Self { inner }, mocked) 169 - } 170 - } 171 - 172 - impl ResolveIdentity for Resolver { 173 - fn resolve<'s: 'i, 'i>( 174 - &'s self, 175 - ident: &'i str, 176 - ) -> BoxFuture<'i, Result<(OwnedDid, DidDocument), ResolveError>> { 177 - Self::resolve(self, ident).boxed() 178 - } 179 - 180 - fn resolve_handle<'s: 'h, 'h>( 181 - &'s self, 182 - handle: &'h str, 183 - ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 184 - Self::resolve_handle(self, handle).boxed() 185 - } 186 - 187 - fn resolve_did<'s: 'd, 'd>( 188 - &'s self, 189 - did: &'d Did, 190 - ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 191 - Self::resolve_did(self, did).boxed() 192 - } 193 - 194 - fn invalidate_did<'s: 'd, 'd>(&'s self, did: &'d Did) -> BoxFuture<'d, ()> { 195 - Self::invalidate_did(self, did).boxed() 196 - } 197 - } 198 - 199 - pub struct ResolverBuilder { 200 - backend: ResolverBackend, 201 - plc_directory: std::borrow::Cow<'static, str>, 202 - cache_capacity: u64, 203 - cache_ttl: std::time::Duration, 204 - } 205 - 206 - impl ResolverBuilder { 207 - #[must_use] 208 - pub fn new() -> Self { 209 - Self { 210 - backend: Default::default(), 211 - plc_directory: DEFAULT_PLC_DIRECTORY.into(), 212 - cache_capacity: 1000, 213 - cache_ttl: std::time::Duration::from_secs(1000), 214 - } 215 - } 216 - 217 - /// Use [`DirectResolver`] as the backend resolver. 218 - /// 219 - /// This resolver does not cache DIDs or DID documents. 220 - #[must_use] 221 - pub const fn direct(mut self) -> Self { 222 - self.backend = ResolverBackend::Direct; 223 - self 224 - } 225 - 226 - /// Use [`MemcacheResolver`] as the backend resolver. 227 - #[must_use] 228 - pub const fn memcache(mut self) -> Self { 229 - self.backend = ResolverBackend::Memcache; 230 - self 231 - } 232 - 233 - /// Set the cache capacity for both DIDs and DID documents. 234 - /// 235 - /// Ignored if backend resolver does not support caching. 236 - #[must_use] 237 - pub const fn cache_capacity(mut self, cap: u64) -> Self { 238 - self.cache_capacity = cap; 239 - self 240 - } 241 - 242 - /// Set the cache Time-To-Live for both DIDs and DID documents. 243 - /// 244 - /// Ignored if backend resolver does not support caching. 245 - #[must_use] 246 - pub const fn cache_ttl(mut self, ttl: std::time::Duration) -> Self { 247 - self.cache_ttl = ttl; 248 - self 249 - } 250 - 251 - pub fn plc_directory( 252 - mut self, 253 - plc_directory: impl Into<std::borrow::Cow<'static, str>>, 254 - ) -> Self { 255 - self.plc_directory = plc_directory.into(); 256 - self 257 - } 258 - 259 - #[must_use] 260 - pub fn build_with(self, http: HttpClient) -> Resolver { 261 - use resolvers::direct::DirectResolver; 262 - use resolvers::memcache::MemcacheResolver; 263 - use std::sync::Arc; 264 - 265 - let inner: Arc<dyn ResolveIdentity + Send + Sync + 'static> = match self.backend { 266 - ResolverBackend::Direct => Arc::new( 267 - DirectResolver::builder() 268 - .plc_directory(self.plc_directory) 269 - .build_with(http), 270 - ), 271 - ResolverBackend::Memcache => { 272 - let resolver = DirectResolver::builder() 273 - .plc_directory(self.plc_directory) 274 - .build_with(http); 275 - Arc::new( 276 - MemcacheResolver::builder() 277 - .capacity(self.cache_capacity) 278 - .ttl(self.cache_ttl) 279 - .build_with(resolver), 280 - ) 281 - } 282 - }; 283 - 284 - Resolver { inner } 285 - } 286 - } 287 - 288 - impl Default for ResolverBuilder { 289 - fn default() -> Self { 290 - Self::new() 291 - } 292 - } 293 - 294 - #[derive(Default)] 295 - enum ResolverBackend { 296 - Direct, 297 - #[default] 298 - Memcache, 299 - } 300 - 301 - #[cfg(test)] 302 - mod tests { 303 - use super::*; 304 - 305 - #[tokio::test] 306 - async fn empty_mock_resolver() { 307 - let (resolver, _) = Resolver::mocked([]); 308 - assert!(matches!( 309 - resolver.resolve("tjh.dev").await, 310 - Err(ResolveError::UnresolvedHandle) 311 - )); 312 - } 313 - 314 - #[tokio::test] 315 - async fn mock_resolver() { 316 - let (resolver, _) = 317 - Resolver::mocked([ 318 - DidDocument::new("did:plc:65gha4t3avpfpzmvpbwovss7", "tjh.dev") 319 - .expect("valid did document"), 320 - ]); 321 - 322 - let (did, doc) = resolver.resolve("tjh.dev").await.expect("resolved id"); 323 - assert_eq!(did.as_ref(), "did:plc:65gha4t3avpfpzmvpbwovss7"); 324 - assert_eq!(doc.primary_alias(), Some("tjh.dev")); 325 - } 326 - }
crates/identity/src/resolvers.rs crates/gordian-identity/src/resolvers.rs
-194
crates/identity/src/resolvers/direct.rs
··· 1 - use std::borrow::Cow; 2 - 3 - use atproto::did::OwnedDid; 4 - use futures_util::{FutureExt as _, future::BoxFuture}; 5 - use hickory_resolver::name_server::TokioConnectionProvider; 6 - use hickory_resolver::{ 7 - ResolveError as DnsResolveError, Resolver as DnsClient, TokioResolver, 8 - name_server::ConnectionProvider, 9 - }; 10 - use tokio::time::Instant; 11 - 12 - use crate::{DEFAULT_PLC_DIRECTORY, Did, DidDocument, ResolveIdentity, HttpClient, ResolveError}; 13 - 14 - pub struct DirectResolver<'plc, R: ConnectionProvider> { 15 - plc: Cow<'plc, str>, 16 - dns: DnsClient<R>, 17 - http: HttpClient, 18 - } 19 - 20 - impl<R: ConnectionProvider> DirectResolver<'_, R> { 21 - async fn fetch_plc_did_document(&self, did: &Did) -> Result<DidDocument, reqwest::Error> { 22 - self.http 23 - .get(format!("{}/{did}", self.plc)) 24 - .send() 25 - .await? 26 - .error_for_status()? 27 - .json() 28 - .await 29 - } 30 - 31 - async fn fetch_web_did_document(&self, did: &Did) -> Result<DidDocument, reqwest::Error> { 32 - self.http 33 - .get(format!("https://{}/.well-known/did.json", did.ident())) 34 - .send() 35 - .await? 36 - .error_for_status()? 37 - .json() 38 - .await 39 - } 40 - } 41 - 42 - impl<R: ConnectionProvider> ResolveIdentity for DirectResolver<'_, R> { 43 - fn resolve_handle<'s: 'h, 'h>( 44 - &'s self, 45 - handle: &'h str, 46 - ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 47 - let dns = resolve_handle_dns(&self.dns, handle); 48 - let http = resolve_handle_http(&self.http, handle); 49 - 50 - // @NOTE This impl races the two resolution methods and uses the first 51 - // one to return a valid result (usually DNS). This may be 52 - // technically incorrect. 53 - 54 - let start = Instant::now(); 55 - async move { 56 - tokio::select! { 57 - Ok(Some(did)) = dns => { 58 - tracing::trace!(?handle, %did, elapsed = ?start.elapsed(), "resolved via dns"); 59 - Ok(did) 60 - }, 61 - Ok(Some(did)) = http => { 62 - tracing::trace!(?handle, %did, elapsed = ?start.elapsed(), "resolved via http"); 63 - Ok(did) 64 - } 65 - else => Err(ResolveError::UnresolvedHandle), 66 - } 67 - } 68 - .boxed() 69 - } 70 - 71 - fn resolve_did<'s: 'd, 'd>( 72 - &'s self, 73 - did: &'d Did, 74 - ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 75 - async { 76 - match did.method() { 77 - "plc" => Ok(self.fetch_plc_did_document(did).await?), 78 - "web" => Ok(self.fetch_web_did_document(did).await?), 79 - method => Err(ResolveError::UnsupportedDidMethod(method.to_string())), 80 - } 81 - } 82 - .boxed() 83 - } 84 - } 85 - 86 - impl DirectResolver<'_, TokioConnectionProvider> { 87 - #[must_use] 88 - pub const fn builder() -> DirectResolverBuilder<'static> { 89 - DirectResolverBuilder { 90 - plc_directory: Cow::Borrowed(DEFAULT_PLC_DIRECTORY), 91 - } 92 - } 93 - } 94 - 95 - impl Default for DirectResolver<'_, TokioConnectionProvider> { 96 - fn default() -> Self { 97 - Self { 98 - plc: Cow::Borrowed(DEFAULT_PLC_DIRECTORY), 99 - dns: TokioResolver::builder_tokio() 100 - .expect("Failed to build default DNS resolver") 101 - .build(), 102 - http: HttpClient::new(), 103 - } 104 - } 105 - } 106 - 107 - impl<R: ConnectionProvider> std::fmt::Debug for DirectResolver<'_, R> { 108 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 109 - f.debug_struct("DirectResolver") 110 - .field("directory", &self.plc) 111 - .finish_non_exhaustive() 112 - } 113 - } 114 - 115 - pub struct DirectResolverBuilder<'plc> { 116 - plc_directory: Cow<'plc, str>, 117 - } 118 - 119 - impl<'plc> DirectResolverBuilder<'plc> { 120 - /// Set the PLC directory to use. Must be a well-formed URL. 121 - /// 122 - /// Defaults to "<https://plc.directory>". 123 - #[must_use] 124 - pub fn plc_directory(mut self, directory: Cow<'plc, str>) -> Self { 125 - self.plc_directory = directory; 126 - self 127 - } 128 - 129 - #[must_use] 130 - pub fn build_with(self, http: HttpClient) -> DirectResolver<'plc, TokioConnectionProvider> { 131 - DirectResolver { 132 - plc: self.plc_directory, 133 - dns: TokioResolver::builder_tokio() 134 - .expect("Failed to build default DNS resolver") 135 - .build(), 136 - http, 137 - } 138 - } 139 - } 140 - 141 - pub async fn resolve_handle_dns<R>( 142 - client: &DnsClient<R>, 143 - handle: &str, 144 - ) -> Result<Option<OwnedDid>, DnsResolveError> 145 - where 146 - R: ConnectionProvider, 147 - { 148 - let mut resolved_did = None; 149 - let txt_lookup = client.txt_lookup(format!("_atproto.{handle}.")).await?; 150 - for record in txt_lookup.iter() { 151 - for txt_data in record.txt_data() { 152 - let Ok(txt) = std::str::from_utf8(txt_data) else { 153 - continue; 154 - }; 155 - let Some(txt_did) = txt.strip_prefix("did=") else { 156 - continue; 157 - }; 158 - let Ok(did) = txt_did.parse::<OwnedDid>() else { 159 - continue; 160 - }; 161 - 162 - if let Some(old_did) = resolved_did.replace(did.clone()) 163 - && old_did != did 164 - { 165 - tracing::error!( 166 - ?handle, 167 - ?did, 168 - ?old_did, 169 - "multiple conflicting DIDs found for handle" 170 - ); 171 - // @TODO Replace this with an error so we can retry with a 172 - // recursive dns resolver. 173 - return Ok(None); 174 - } 175 - } 176 - } 177 - 178 - Ok(resolved_did) 179 - } 180 - 181 - pub async fn resolve_handle_http( 182 - client: &HttpClient, 183 - handle: &str, 184 - ) -> Result<Option<OwnedDid>, reqwest::Error> { 185 - let response = client 186 - .get(format!("https://{handle}/.well-known/atproto-did")) 187 - .send() 188 - .await? 189 - .error_for_status()? 190 - .text() 191 - .await?; 192 - 193 - Ok(response.trim().parse().ok()) 194 - }
-153
crates/identity/src/resolvers/memcache.rs
··· 1 - use std::{sync::Arc, time::Duration}; 2 - 3 - use atproto::did::OwnedDid; 4 - use futures_util::{FutureExt as _, TryFutureExt as _, future::BoxFuture}; 5 - use moka::future::{Cache, CacheBuilder}; 6 - 7 - use crate::{Did, DidDocument, ResolveError, ResolveIdentity}; 8 - 9 - const DEFAULT_DID_CACHE_CAP: u64 = 1024; 10 - const DEFAULT_DOC_CACHE_CAP: u64 = 1024; 11 - 12 - const DEFAULT_DID_CACHE_TTL: Duration = Duration::from_secs(600); 13 - const DEFAULT_DOC_CACHE_TTL: Duration = Duration::from_secs(600); 14 - 15 - /// An indentity resolver with an in-memory cache. 16 - #[derive(Debug)] 17 - pub struct MemcacheResolver { 18 - did_cache: Cache<Box<str>, OwnedDid>, 19 - doc_cache: Cache<OwnedDid, DidDocument>, 20 - inner: Box<dyn ResolveIdentity + Send + 'static>, 21 - } 22 - 23 - impl MemcacheResolver { 24 - /// Create a memory-caching resolver wrapping another resolver. 25 - pub fn wrap<R>(resolver: R) -> Self 26 - where 27 - R: ResolveIdentity + Send + 'static, 28 - { 29 - let did_cache = CacheBuilder::new(DEFAULT_DID_CACHE_CAP) 30 - .time_to_live(DEFAULT_DID_CACHE_TTL) 31 - .build(); 32 - 33 - let doc_cache = CacheBuilder::new(DEFAULT_DOC_CACHE_CAP) 34 - .time_to_live(DEFAULT_DOC_CACHE_TTL) 35 - .build(); 36 - 37 - Self { 38 - did_cache, 39 - doc_cache, 40 - inner: Box::new(resolver), 41 - } 42 - } 43 - 44 - #[must_use] 45 - pub const fn builder() -> MemcacheResolverBuilder { 46 - MemcacheResolverBuilder { 47 - did_cache_capacity: DEFAULT_DID_CACHE_CAP, 48 - did_cache_ttl: DEFAULT_DID_CACHE_TTL, 49 - doc_cache_capacity: DEFAULT_DOC_CACHE_CAP, 50 - doc_cache_ttl: DEFAULT_DOC_CACHE_TTL, 51 - } 52 - } 53 - } 54 - 55 - impl ResolveIdentity for MemcacheResolver { 56 - fn resolve_handle<'s: 'h, 'h>( 57 - &'s self, 58 - handle: &'h str, 59 - ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 60 - self.did_cache 61 - .try_get_with(handle.into(), self.inner.resolve_handle(handle)) 62 - .map_err(Arc::unwrap_or_clone) 63 - .boxed() 64 - } 65 - 66 - fn resolve_did<'s: 'd, 'd>( 67 - &'s self, 68 - did: &'d Did, 69 - ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 70 - self.doc_cache 71 - .try_get_with_by_ref(did, self.inner.resolve_did(did)) 72 - .map_err(Arc::unwrap_or_clone) 73 - .boxed() 74 - } 75 - 76 - fn invalidate_did<'s: 'd, 'd>(&'s self, did: &'d Did) -> BoxFuture<'d, ()> { 77 - async move { 78 - if let Some(doc) = self.doc_cache.remove(did).await { 79 - tracing::trace!(?did, "invalidating DID document"); 80 - for handle in doc.also_known_as.iter().filter_map(|uri| uri.domain()) { 81 - tracing::trace!(?did, ?handle, "invalidating handle from DID"); 82 - self.did_cache.remove(handle).await; 83 - } 84 - } 85 - } 86 - .boxed() 87 - } 88 - } 89 - 90 - pub struct MemcacheResolverBuilder { 91 - did_cache_capacity: u64, 92 - did_cache_ttl: Duration, 93 - 94 - doc_cache_capacity: u64, 95 - doc_cache_ttl: Duration, 96 - } 97 - 98 - impl MemcacheResolverBuilder { 99 - /// Set the DID cache capacity and the DID document cache capacity. 100 - #[must_use] 101 - pub const fn capacity(self, capacity: u64) -> Self { 102 - self.did_capacity(capacity).doc_capacity(capacity) 103 - } 104 - 105 - /// Set the DID cache ttl and the DID document cache ttl. 106 - #[must_use] 107 - pub const fn ttl(self, ttl: Duration) -> Self { 108 - self.did_ttl(ttl).doc_ttl(ttl) 109 - } 110 - 111 - #[must_use] 112 - pub const fn did_capacity(mut self, capacity: u64) -> Self { 113 - self.did_cache_capacity = capacity; 114 - self 115 - } 116 - 117 - #[must_use] 118 - pub const fn doc_capacity(mut self, capacity: u64) -> Self { 119 - self.doc_cache_capacity = capacity; 120 - self 121 - } 122 - 123 - #[must_use] 124 - pub const fn did_ttl(mut self, ttl: Duration) -> Self { 125 - self.did_cache_ttl = ttl; 126 - self 127 - } 128 - 129 - #[must_use] 130 - pub const fn doc_ttl(mut self, ttl: Duration) -> Self { 131 - self.doc_cache_ttl = ttl; 132 - self 133 - } 134 - 135 - pub fn build_with<R>(self, resolver: R) -> MemcacheResolver 136 - where 137 - R: ResolveIdentity + Send + 'static, 138 - { 139 - let did_cache = CacheBuilder::new(self.did_cache_capacity) 140 - .time_to_live(self.did_cache_ttl) 141 - .build(); 142 - 143 - let doc_cache = CacheBuilder::new(self.doc_cache_capacity) 144 - .time_to_live(self.doc_cache_ttl) 145 - .build(); 146 - 147 - MemcacheResolver { 148 - did_cache, 149 - doc_cache, 150 - inner: Box::new(resolver), 151 - } 152 - } 153 - }
-93
crates/identity/src/resolvers/mock.rs
··· 1 - use std::{ 2 - collections::HashMap, 3 - sync::{Arc, Mutex}, 4 - }; 5 - 6 - use atproto::did::OwnedDid; 7 - use futures_util::{FutureExt, future::BoxFuture}; 8 - 9 - use crate::{DidDocument, ResolveError, ResolveIdentity}; 10 - 11 - #[derive(Debug, Default)] 12 - struct Inner { 13 - handle_did: HashMap<String, OwnedDid>, 14 - did_document: HashMap<OwnedDid, DidDocument>, 15 - } 16 - 17 - #[derive(Clone, Debug, Default)] 18 - pub struct MockResolver { 19 - inner: Arc<Mutex<Inner>>, 20 - } 21 - 22 - impl MockResolver { 23 - pub fn new(documents: impl IntoIterator<Item = DidDocument>) -> Self { 24 - let mut handle_did = HashMap::default(); 25 - let mut did_document = HashMap::default(); 26 - for (handle, did_val, did_key, document) in documents.into_iter().map(split_document) { 27 - handle_did.insert(handle, did_val); 28 - did_document.insert(did_key, document); 29 - } 30 - 31 - let inner = Arc::new(Mutex::new(Inner { 32 - handle_did, 33 - did_document, 34 - })); 35 - 36 - Self { inner } 37 - } 38 - 39 - /// Add an identity to the mock resolver. 40 - pub fn insert(&self, document: DidDocument) { 41 - let (handle, did_val, did_key, document) = split_document(document); 42 - let mut map = self.inner.lock().expect("unpoisoned mutex"); 43 - map.handle_did.insert(handle, did_val); 44 - map.did_document.insert(did_key, document); 45 - } 46 - } 47 - 48 - fn split_document(document: DidDocument) -> (String, OwnedDid, OwnedDid, DidDocument) { 49 - let handle = document 50 - .primary_alias() 51 - .expect("aliased identity") 52 - .to_owned(); 53 - 54 - let did = &document.id; 55 - 56 - (handle, did.to_owned(), did.to_owned(), document) 57 - } 58 - 59 - impl FromIterator<DidDocument> for MockResolver { 60 - fn from_iter<T: IntoIterator<Item = DidDocument>>(iter: T) -> Self { 61 - Self::new(iter) 62 - } 63 - } 64 - 65 - impl ResolveIdentity for MockResolver { 66 - fn resolve_handle<'s: 'h, 'h>( 67 - &'s self, 68 - handle: &'h str, 69 - ) -> BoxFuture<'h, Result<OwnedDid, ResolveError>> { 70 - async move { 71 - let map = self.inner.lock().expect("unpoisoned mutex"); 72 - map.handle_did 73 - .get(handle) 74 - .ok_or(ResolveError::UnresolvedHandle) 75 - .cloned() 76 - } 77 - .boxed() 78 - } 79 - 80 - fn resolve_did<'s: 'd, 'd>( 81 - &'s self, 82 - did: &'d atproto::Did, 83 - ) -> BoxFuture<'d, Result<DidDocument, ResolveError>> { 84 - async move { 85 - let map = self.inner.lock().expect("unpoisoned mutex"); 86 - map.did_document 87 - .get(did) 88 - .ok_or(ResolveError::UnresolvedHandle) 89 - .cloned() 90 - } 91 - .boxed() 92 - } 93 - }
-38
crates/jetstream/Cargo.toml
··· 1 - [package] 2 - name = "jetstream" 3 - version.workspace = true 4 - authors.workspace = true 5 - repository.workspace = true 6 - license.workspace = true 7 - edition.workspace = true 8 - publish.workspace = true 9 - 10 - [dependencies] 11 - atproto = { workspace = true, features = ["serde"] } 12 - 13 - serde.workspace = true 14 - serde_json.workspace = true 15 - time.workspace = true 16 - tracing.workspace = true 17 - url.workspace = true 18 - 19 - bytes = "1.10.1" 20 - flume = "0.11.1" 21 - futures-util = "0.3.31" 22 - tokio = { version = "1.48.0", features = ["macros", "rt", "sync", "time"] } 23 - tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } 24 - tokio-util = "0.7.17" 25 - tracing-subscriber = "0.3.20" 26 - 27 - zstd = { version = "0.13.3", optional = true } 28 - clap = { version = "4.5.50", features = ["derive"], optional = true } 29 - thiserror.workspace = true 30 - fastrand = "2.3.0" 31 - 32 - [features] 33 - default = ["clap", "zstd"] 34 - 35 - [[bin]] 36 - name = "jetstream" 37 - path = "src/main.rs" 38 - required-features = ["clap"]
-222
crates/jetstream/src/client.rs
··· 1 - use crate::{ 2 - Nsid, 3 - de::Event, 4 - metrics::{Metrics, MetricsData}, 5 - subscriber_options::SubscriberOptions, 6 - task::JetstreamTaskError, 7 - }; 8 - use atproto::did::OwnedDid; 9 - use bytes::Bytes; 10 - use std::sync::{Arc, Mutex}; 11 - use tokio::sync::oneshot; 12 - use tokio_util::sync::{CancellationToken, DropGuard}; 13 - 14 - #[derive(Debug)] 15 - pub struct JetstreamClient { 16 - client_tx: flume::Sender<ClientCommand>, 17 - options: Arc<Mutex<SubscriberOptions>>, 18 - metrics: Metrics, 19 - #[allow(unused)] 20 - shutdown: DropGuard, 21 - } 22 - 23 - impl JetstreamClient { 24 - pub(crate) fn new( 25 - client_tx: flume::Sender<ClientCommand>, 26 - options: Arc<Mutex<SubscriberOptions>>, 27 - metrics: Metrics, 28 - shutdown: CancellationToken, 29 - ) -> Self { 30 - Self { 31 - client_tx, 32 - options, 33 - metrics, 34 - shutdown: shutdown.drop_guard(), 35 - } 36 - } 37 - 38 - /// Add a DID to the Jetstream filters. 39 - pub async fn add_did(&self, did: impl Into<OwnedDid>) -> Result<(), JetstreamClientError> { 40 - if self.options.lock().unwrap().add_did(did.into())? { 41 - // The DID is new to the client, notify the task to update. 42 - self.update_task().await?; 43 - } 44 - Ok(()) 45 - } 46 - 47 - /// Remove a DID from the Jetstream filters. 48 - pub async fn remove_did(&self, did: impl Into<OwnedDid>) -> Result<(), JetstreamClientError> { 49 - if self.options.lock().unwrap().remove_did(&did.into()) { 50 - self.update_task().await?; 51 - } 52 - Ok(()) 53 - } 54 - 55 - /// Add a collection to the Jetstream filters. 56 - pub async fn add_collection( 57 - &self, 58 - collection: impl Into<Box<Nsid>>, 59 - ) -> Result<(), JetstreamClientError> { 60 - if self 61 - .options 62 - .lock() 63 - .unwrap() 64 - .add_collection(collection.into())? 65 - { 66 - // The collection is new to the client, notify the task to update. 67 - self.update_task().await?; 68 - } 69 - Ok(()) 70 - } 71 - 72 - /// Remove a collection from the Jetstream filters. 73 - pub async fn remove_collection( 74 - &self, 75 - collection: impl Into<Box<Nsid>>, 76 - ) -> Result<(), JetstreamClientError> { 77 - if self 78 - .options 79 - .lock() 80 - .unwrap() 81 - .remove_collection(&collection.into()) 82 - { 83 - self.update_task().await?; 84 - } 85 - Ok(()) 86 - } 87 - 88 - #[must_use] 89 - pub fn metrics(&self) -> MetricsData { 90 - self.metrics.export() 91 - } 92 - 93 - pub async fn shutdown(self) -> Result<(), JetstreamClientError> { 94 - let (command, complete) = ClientCommand::shutdown(); 95 - self.client_tx.send(command)?; 96 - complete.await??; 97 - Ok(()) 98 - } 99 - 100 - async fn update_task(&self) -> Result<(), JetstreamClientError> { 101 - let (command, complete) = ClientCommand::subscriber_options_update(); 102 - self.client_tx.send(command)?; 103 - complete.await??; 104 - Ok(()) 105 - } 106 - } 107 - 108 - pub type CommandResponse<T> = oneshot::Sender<Result<T, JetstreamTaskError>>; 109 - 110 - pub enum ClientCommand { 111 - SubscriberOptionsUpdate(CommandResponse<()>), 112 - Shutdown(CommandResponse<()>), 113 - } 114 - 115 - impl ClientCommand { 116 - fn subscriber_options_update() -> (Self, oneshot::Receiver<Result<(), JetstreamTaskError>>) { 117 - let (tx, rx) = oneshot::channel(); 118 - (Self::SubscriberOptionsUpdate(tx), rx) 119 - } 120 - 121 - fn shutdown() -> (Self, oneshot::Receiver<Result<(), JetstreamTaskError>>) { 122 - let (tx, rx) = oneshot::channel(); 123 - (Self::Shutdown(tx), rx) 124 - } 125 - } 126 - 127 - #[derive(Debug, thiserror::Error)] 128 - pub enum JetstreamClientError { 129 - #[error("Client task shutdown")] 130 - TaskShutdown, 131 - #[error("Error in jetstream client task: {0}")] 132 - TaskError(#[from] JetstreamTaskError), 133 - #[error("DID filter exceeds maximum size")] 134 - TooManyDids(OwnedDid), 135 - #[error("Collection filter exceeds maximum size")] 136 - TooManyCollections(Box<Nsid>), 137 - } 138 - 139 - impl<T> From<flume::SendError<T>> for JetstreamClientError { 140 - fn from(_: flume::SendError<T>) -> Self { 141 - Self::TaskShutdown 142 - } 143 - } 144 - 145 - impl From<oneshot::error::RecvError> for JetstreamClientError { 146 - fn from(_: oneshot::error::RecvError) -> Self { 147 - Self::TaskShutdown 148 - } 149 - } 150 - 151 - impl From<OwnedDid> for JetstreamClientError { 152 - fn from(value: OwnedDid) -> Self { 153 - Self::TooManyDids(value) 154 - } 155 - } 156 - 157 - impl From<Box<Nsid>> for JetstreamClientError { 158 - fn from(value: Box<Nsid>) -> Self { 159 - Self::TooManyCollections(value) 160 - } 161 - } 162 - 163 - #[derive(Debug)] 164 - pub struct JetstreamReceiver { 165 - event_rx: flume::Receiver<Bytes>, 166 - } 167 - 168 - impl JetstreamReceiver { 169 - pub(crate) const fn new(event_rx: flume::Receiver<Bytes>) -> Self { 170 - Self { event_rx } 171 - } 172 - 173 - /// Asynchronously receive a Jetstream event. 174 - /// 175 - /// Returns [`None`] when the Jetstream client is shutdown. 176 - pub async fn recv_async(&self) -> Option<JetstreamEvent> { 177 - let bytes = self.event_rx.recv_async().await.ok()?; 178 - Some(JetstreamEvent::new(bytes)) 179 - } 180 - 181 - /// Synchronously receive a Jetstream event. 182 - /// 183 - /// Returns [`None`] when the Jetstream client is shutdown. 184 - #[must_use] 185 - pub fn recv(&self) -> Option<JetstreamEvent> { 186 - let bytes = self.event_rx.recv().ok()?; 187 - Some(JetstreamEvent::new(bytes)) 188 - } 189 - 190 - /// Consume the Jetstream receiver and return the wrapped flume channel receiver. 191 - #[must_use] 192 - pub fn to_inner(self) -> flume::Receiver<Bytes> { 193 - self.event_rx 194 - } 195 - } 196 - 197 - #[derive(Debug)] 198 - pub struct JetstreamEvent { 199 - bytes: Bytes, 200 - } 201 - 202 - impl JetstreamEvent { 203 - pub fn as_bytes(&self) -> &[u8] { 204 - &self.bytes 205 - } 206 - 207 - /// Consume the event, returning the internal [`Bytes`] buffer. 208 - pub fn to_inner(self) -> Bytes { 209 - self.bytes 210 - } 211 - 212 - const fn new(bytes: Bytes) -> Self { 213 - Self { bytes } 214 - } 215 - } 216 - 217 - impl<'a> JetstreamEvent { 218 - pub fn deserialize(&'a self) -> Result<Event<'a>, serde_json::Error> { 219 - let value = serde_json::from_slice(&self.bytes)?; 220 - Ok(value) 221 - } 222 - }
crates/jetstream/src/client_config.rs crates/gordian-jetstream/src/client_config.rs
crates/jetstream/src/client_options.rs crates/gordian-jetstream/src/client_options.rs
-338
crates/jetstream/src/de.rs
··· 1 - use atproto::Did; 2 - use serde::{Deserialize, Serialize, de::Visitor}; 3 - use serde_json::value::RawValue; 4 - use time::OffsetDateTime; 5 - 6 - // @NOTE 7 - // 8 - // Using `serde_json::value::RawValue` breaks if any part of the deserialization 9 - // features a tagged enum or `#[serde(flatten)]` is used. 10 - // 11 - // To get around this limitation we first deserialize into a less ideal type, 12 - // then manually transform into the types we want. 13 - 14 - #[derive(Debug, Deserialize, Serialize)] 15 - struct InnerEvent<'a> { 16 - #[serde(borrow)] 17 - did: &'a Did, 18 - time_us: i64, 19 - kind: &'a str, 20 - commit: Option<InnerCommit<'a>>, 21 - identity: Option<InnerIdentity<'a>>, 22 - account: Option<InnerAccount<'a>>, 23 - } 24 - 25 - #[derive(Debug, Deserialize, Serialize)] 26 - struct InnerCommit<'a> { 27 - #[serde(borrow)] 28 - rev: &'a str, 29 - operation: &'a str, 30 - collection: &'a str, 31 - rkey: &'a str, 32 - cid: Option<&'a str>, 33 - record: Option<&'a RawValue>, 34 - } 35 - 36 - #[derive(Debug, Deserialize, Serialize)] 37 - struct InnerIdentity<'a> { 38 - #[serde(borrow)] 39 - did: &'a Did, 40 - handle: Option<&'a str>, 41 - seq: i64, 42 - #[serde(with = "time::serde::rfc3339")] 43 - time: OffsetDateTime, 44 - } 45 - 46 - #[derive(Debug, Deserialize, Serialize)] 47 - pub struct InnerAccount<'a> { 48 - #[serde(default)] 49 - pub active: bool, 50 - #[serde(borrow)] 51 - pub did: &'a Did, 52 - pub handle: Option<&'a str>, 53 - #[serde(default)] 54 - pub status: AccountStatus, 55 - pub seq: i64, 56 - #[serde(with = "time::serde::rfc3339")] 57 - pub time: OffsetDateTime, 58 - } 59 - 60 - #[derive(Debug, Default)] 61 - pub enum AccountStatus { 62 - Active, 63 - Takendown, 64 - Suspended, 65 - Deleted, 66 - Deactivated, 67 - Desynchronized, 68 - Other(Box<str>), 69 - #[default] 70 - None, 71 - } 72 - 73 - impl<'de> Deserialize<'de> for AccountStatus { 74 - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 75 - where 76 - D: serde::Deserializer<'de>, 77 - { 78 - struct StatusVisitor; 79 - 80 - impl Visitor<'_> for StatusVisitor { 81 - type Value = AccountStatus; 82 - 83 - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 84 - formatter.write_str("Account Status") 85 - } 86 - 87 - fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 88 - where 89 - E: serde::de::Error, 90 - { 91 - match v { 92 - "active" => Ok(AccountStatus::Active), 93 - "takendown" => Ok(AccountStatus::Takendown), 94 - "suspened" => Ok(AccountStatus::Suspended), 95 - "deleted" => Ok(AccountStatus::Deleted), 96 - "deactivated" => Ok(AccountStatus::Deactivated), 97 - "desynchronized" => Ok(AccountStatus::Desynchronized), 98 - status => { 99 - tracing::warn!(?status, "unexpected account status"); 100 - Ok(AccountStatus::Other(v.into())) 101 - } 102 - } 103 - } 104 - 105 - fn visit_none<E>(self) -> Result<Self::Value, E> 106 - where 107 - E: serde::de::Error, 108 - { 109 - Ok(AccountStatus::None) 110 - } 111 - 112 - fn visit_unit<E>(self) -> Result<Self::Value, E> 113 - where 114 - E: serde::de::Error, 115 - { 116 - Ok(AccountStatus::None) 117 - } 118 - } 119 - 120 - deserializer.deserialize_any(StatusVisitor) 121 - } 122 - } 123 - 124 - impl Serialize for AccountStatus { 125 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 126 - where 127 - S: serde::Serializer, 128 - { 129 - match self { 130 - Self::Active => serializer.serialize_str("active"), 131 - Self::Takendown => serializer.serialize_str("takendown"), 132 - Self::Suspended => serializer.serialize_str("suspended"), 133 - Self::Deleted => serializer.serialize_str("deleted"), 134 - Self::Deactivated => serializer.serialize_str("deactivated"), 135 - Self::Desynchronized => serializer.serialize_str("desynchronized"), 136 - Self::Other(value) => serializer.serialize_str(value), 137 - Self::None => serializer.serialize_none(), 138 - } 139 - } 140 - } 141 - 142 - #[derive(Debug, Deserialize)] 143 - #[serde(try_from = "InnerEvent")] 144 - pub enum Event<'a> { 145 - Commit(#[serde(borrow)] CommitEvent<'a>), 146 - Account(Account<'a>), 147 - Identity(Identity<'a>), 148 - } 149 - 150 - impl<'a> Event<'a> { 151 - #[must_use] 152 - pub const fn did(&'a self) -> &'a Did { 153 - match self { 154 - Self::Commit(commit) => commit.did(), 155 - Self::Account(account) => account.did, 156 - Self::Identity(identity) => identity.did, 157 - } 158 - } 159 - 160 - #[must_use] 161 - pub const fn ts(&self) -> OffsetDateTime { 162 - match self { 163 - Self::Commit(commit) => commit.ts(), 164 - Self::Account(account) => account.ts, 165 - Self::Identity(identity) => identity.ts, 166 - } 167 - } 168 - } 169 - 170 - #[derive(Debug)] 171 - pub enum CommitEvent<'a> { 172 - Create(Commit<'a>), 173 - Update(Commit<'a>), 174 - Delete(Delete<'a>), 175 - } 176 - 177 - impl<'a> CommitEvent<'a> { 178 - #[must_use] 179 - pub const fn ts(&self) -> OffsetDateTime { 180 - match self { 181 - Self::Create(commit) => commit.ts, 182 - Self::Update(commit) => commit.ts, 183 - Self::Delete(commit) => commit.ts, 184 - } 185 - } 186 - 187 - #[must_use] 188 - pub const fn did(&'a self) -> &'a Did { 189 - match self { 190 - Self::Create(commit) => commit.did, 191 - Self::Update(commit) => commit.did, 192 - Self::Delete(commit) => commit.did, 193 - } 194 - } 195 - 196 - #[inline] 197 - #[must_use] 198 - pub const fn did_str(&'a self) -> &'a str { 199 - self.did().as_str() 200 - } 201 - 202 - #[must_use] 203 - pub const fn collection(&self) -> &str { 204 - match self { 205 - Self::Create(commit) => commit.collection, 206 - Self::Update(commit) => commit.collection, 207 - Self::Delete(commit) => commit.collection, 208 - } 209 - } 210 - 211 - #[must_use] 212 - pub const fn rkey(&self) -> &str { 213 - match self { 214 - Self::Create(commit) => commit.rkey, 215 - Self::Update(commit) => commit.rkey, 216 - Self::Delete(commit) => commit.rkey, 217 - } 218 - } 219 - 220 - #[must_use] 221 - pub const fn rev(&self) -> &str { 222 - match self { 223 - Self::Create(commit) => commit.rev, 224 - Self::Update(commit) => commit.rev, 225 - Self::Delete(commit) => commit.rev, 226 - } 227 - } 228 - } 229 - 230 - #[derive(Debug)] 231 - pub struct Commit<'a> { 232 - pub ts: OffsetDateTime, 233 - pub did: &'a Did, 234 - pub collection: &'a str, 235 - pub rkey: &'a str, 236 - pub rev: &'a str, 237 - pub cid: &'a str, 238 - pub record: &'a RawValue, 239 - } 240 - 241 - #[derive(Debug, Deserialize, Serialize)] 242 - pub struct Delete<'a> { 243 - pub ts: OffsetDateTime, 244 - #[serde(borrow)] 245 - pub did: &'a Did, 246 - pub collection: &'a str, 247 - pub rkey: &'a str, 248 - pub rev: &'a str, 249 - } 250 - 251 - #[derive(Debug)] 252 - pub struct Account<'a> { 253 - pub ts: OffsetDateTime, 254 - pub active: bool, 255 - pub did: &'a Did, 256 - pub handle: Option<&'a str>, 257 - pub status: AccountStatus, 258 - pub seq: i64, 259 - pub time: OffsetDateTime, 260 - } 261 - 262 - #[derive(Debug)] 263 - pub struct Identity<'a> { 264 - pub ts: OffsetDateTime, 265 - pub did: &'a Did, 266 - pub handle: Option<&'a str>, 267 - pub seq: i64, 268 - pub time: OffsetDateTime, 269 - } 270 - 271 - impl<'a> TryFrom<InnerEvent<'a>> for Event<'a> { 272 - type Error = &'static str; 273 - 274 - fn try_from(value: InnerEvent<'a>) -> Result<Self, Self::Error> { 275 - let ts = OffsetDateTime::from_unix_timestamp_nanos(i128::from(value.time_us) * 1000) 276 - .map_err(|_| "Failed to parse timestamp")?; 277 - match (value.kind, value.commit, value.account, value.identity) { 278 - ("commit", Some(commit), None, None) => { 279 - match (commit.operation, commit.cid, commit.record) { 280 - ("create", Some(cid), Some(record)) => { 281 - Ok(Self::Commit(CommitEvent::Create(Commit { 282 - ts, 283 - did: value.did, 284 - collection: commit.collection, 285 - rkey: commit.rkey, 286 - rev: commit.rev, 287 - cid, 288 - record, 289 - }))) 290 - } 291 - 292 - ("create", None, _) => Err("missing 'cid' field in commit create"), 293 - ("create", _, None) => Err("missing 'record' field in commit create"), 294 - ("update", Some(cid), Some(record)) => { 295 - Ok(Self::Commit(CommitEvent::Update(Commit { 296 - ts, 297 - did: value.did, 298 - collection: commit.collection, 299 - rkey: commit.rkey, 300 - rev: commit.rev, 301 - cid, 302 - record, 303 - }))) 304 - } 305 - ("update", None, _) => Err("missing 'cid' field in commit update"), 306 - ("update", _, None) => Err("missing 'record' field in commit update"), 307 - ("delete", None, None) => Ok(Self::Commit(CommitEvent::Delete(Delete { 308 - ts, 309 - did: value.did, 310 - collection: commit.collection, 311 - rkey: commit.rkey, 312 - rev: commit.rev, 313 - }))), 314 - _ => Err("unexpected operation"), 315 - } 316 - 317 - // 318 - } 319 - ("account", None, Some(account), None) => Ok(Self::Account(Account { 320 - ts, 321 - active: account.active, 322 - did: account.did, 323 - handle: account.handle, 324 - status: account.status, 325 - seq: account.seq, 326 - time: account.time, 327 - })), 328 - ("identity", None, None, Some(identity)) => Ok(Self::Identity(Identity { 329 - ts, 330 - did: identity.did, 331 - handle: identity.handle, 332 - seq: identity.seq, 333 - time: identity.time, 334 - })), 335 - _ => Err("unexpected event kind"), 336 - } 337 - } 338 - }
crates/jetstream/src/dictionary crates/gordian-jetstream/src/dictionary

This is a binary file and will not be displayed.

-26
crates/jetstream/src/lib.rs
··· 1 - mod client; 2 - mod de; 3 - mod task; 4 - 5 - pub mod client_config; 6 - pub mod client_options; 7 - pub mod metrics; 8 - pub mod subscriber_options; 9 - 10 - pub use atproto::{Did, Nsid}; 11 - pub use client::{JetstreamClient, JetstreamClientError, JetstreamEvent, JetstreamReceiver}; 12 - pub use de::{AccountStatus, Commit, CommitEvent, Delete, Event, Identity, InnerAccount}; 13 - pub use serde_json::Value; 14 - 15 - pub const PUBLIC_JETSTREAM_US_EAST1: &str = "wss://jetstream1.us-east.bsky.network"; 16 - pub const PUBLIC_JETSTREAM_US_EAST2: &str = "wss://jetstream2.us-east.bsky.network"; 17 - pub const PUBLIC_JETSTREAM_US_WEST1: &str = "wss://jetstream1.us-west.bsky.network"; 18 - pub const PUBLIC_JETSTREAM_US_WEST2: &str = "wss://jetstream2.us-west.bsky.network"; 19 - 20 - /// Official public Jetstream instances. 21 - pub const PUBLIC_JETSTREAM_INSTANCES: &[&str] = &[ 22 - PUBLIC_JETSTREAM_US_EAST1, 23 - PUBLIC_JETSTREAM_US_EAST2, 24 - PUBLIC_JETSTREAM_US_WEST1, 25 - PUBLIC_JETSTREAM_US_WEST2, 26 - ];
-95
crates/jetstream/src/main.rs
··· 1 - mod cli { 2 - use std::path::PathBuf; 3 - 4 - use clap::Parser; 5 - 6 - #[derive(Parser)] 7 - pub struct Arguments { 8 - /// Initial cursor in seconds. 9 - #[arg(long, short = 'C', allow_negative_numbers = true)] 10 - pub cursor: Option<i64>, 11 - 12 - /// Don't print Account events. 13 - #[arg(long)] 14 - pub hide_account: bool, 15 - 16 - /// Don't print Identity events. 17 - #[arg(long)] 18 - pub hide_identity: bool, 19 - 20 - #[arg(long)] 21 - pub log: Option<PathBuf>, 22 - 23 - /// NSIDs or DIDs to filter. 24 - pub filter: Vec<String>, 25 - } 26 - 27 - pub fn parse() -> Arguments { 28 - Parser::parse() 29 - } 30 - } 31 - 32 - use std::{ 33 - fs::File, 34 - io::Write, 35 - time::{Duration, SystemTime, UNIX_EPOCH}, 36 - }; 37 - 38 - use jetstream::{Event, client_config::JetstreamConfig}; 39 - 40 - #[tokio::main(flavor = "current_thread")] 41 - async fn main() { 42 - tracing_subscriber::fmt::init(); 43 - 44 - let arguments = cli::parse(); 45 - let mut log = arguments 46 - .log 47 - .map(|path| File::options().create(true).append(true).open(path)) 48 - .transpose() 49 - .expect("Failed to open log file"); 50 - 51 - let cursor = match arguments.cursor { 52 - Some(value) if value < 0 => { 53 - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); 54 - let offset = Duration::from_secs(value.unsigned_abs()); 55 - Some(now.checked_sub(offset).unwrap().as_micros()) 56 - } 57 - Some(value) => Some(value.unsigned_abs().into()), 58 - None => None, 59 - }; 60 - 61 - let mut config = JetstreamConfig::default().with_cursor(cursor); 62 - for mut filter in arguments.filter.iter().cloned() { 63 - if let Ok(did) = filter.parse() { 64 - config.subscriber_options.add_did(did).unwrap(); 65 - } else { 66 - if filter.ends_with('.') { 67 - filter.push('*'); 68 - } 69 - config 70 - .subscriber_options 71 - .add_collection(filter.try_into().unwrap()) 72 - .unwrap(); 73 - } 74 - } 75 - 76 - tracing::debug!(?config); 77 - let (client, rx, task) = config.connect(); 78 - 79 - // Spawn the client task. 80 - let handle = tokio::spawn(task); 81 - 82 - while let Some(message) = rx.recv_async().await { 83 - let msg = String::from_utf8_lossy(message.as_bytes()); 84 - if let Some(log) = &mut log { 85 - writeln!(log, "{msg}").expect("Failed to write to log file"); 86 - } 87 - 88 - if let Ok(Event::Commit(commit)) = message.deserialize() { 89 - println!("{commit:#?}"); 90 - eprintln!("{:?}", client.metrics()); 91 - } 92 - } 93 - 94 - handle.await.unwrap(); 95 - }
crates/jetstream/src/metrics.rs crates/gordian-jetstream/src/metrics.rs
-318
crates/jetstream/src/subscriber_options.rs
··· 1 - use std::collections::HashSet; 2 - 3 - use atproto::did::OwnedDid; 4 - use serde::{Deserialize, Serialize}; 5 - 6 - use crate::{Did, Nsid}; 7 - 8 - pub const MAX_WANTED_COLLECTIONS: usize = 100; 9 - 10 - pub const MAX_WANTED_DIDS: usize = 10_000; 11 - 12 - // @TODO Review 13 - pub const MAX_URL_LENGTH: usize = 4000; 14 - 15 - /// Jetstream subscription options. 16 - /// 17 - /// Can either be appended to the `/subscribe` URL on connection to the Jetstream instance 18 - /// or sent as an options update message after connection. 19 - /// 20 - /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#options-updates> 21 - /// 22 - #[derive(Clone, Debug, Default, Deserialize, Serialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct SubscriberOptions { 25 - /// Collection NSIDs to filter which records are received. 26 - /// 27 - /// Maximum: 100 28 - pub wanted_collections: HashSet<Box<Nsid>>, 29 - 30 - /// Repository DIDs to filter which records are received. 31 - /// 32 - /// Maximum: `10_000` 33 - pub wanted_dids: HashSet<OwnedDid>, 34 - 35 - /// Maximum message size in bytes the subscriber wants to receive. 36 - /// 37 - /// Zero means no limit, negative values are treated as zero by Jetstream, and 38 - /// will be normalized to zero when serialized. 39 - #[serde(with = "max_message_size")] 40 - pub max_message_size_bytes: i64, 41 - 42 - pub cursor: Option<u128>, 43 - } 44 - 45 - impl SubscriberOptions { 46 - /// Add a collection NSID to the subscription options. 47 - /// 48 - /// Returns an error if the maximum number of subscribed collections has been reached; `Ok(true)` 49 - /// if the collection was newly added to the set, or `Ok(false)` if the colletion was already in the 50 - /// the set. 51 - pub fn add_collection(&mut self, collection: Box<Nsid>) -> Result<bool, Box<Nsid>> { 52 - if self.wanted_collections.len() == MAX_WANTED_COLLECTIONS 53 - && !self.wanted_collections.contains(&collection) 54 - { 55 - return Err(collection); 56 - } 57 - 58 - Ok(self.wanted_collections.insert(collection)) 59 - } 60 - 61 - pub fn remove_collection(&mut self, collection: &Nsid) -> bool { 62 - self.wanted_collections.remove(collection) 63 - } 64 - 65 - /// Add a DID to the subscription options. 66 - /// 67 - /// Returns an error if the maximum number of subscribed DIDs has been reached; `Ok(true)` 68 - /// if the DID was newly added to the set, or `Ok(false)` if the DID was already in the 69 - /// the set. 70 - pub fn add_did(&mut self, did: OwnedDid) -> Result<bool, OwnedDid> { 71 - if self.wanted_dids.len() == MAX_WANTED_DIDS && !self.wanted_dids.contains(&did) { 72 - return Err(did); 73 - } 74 - 75 - Ok(self.wanted_dids.insert(did)) 76 - } 77 - 78 - pub fn remove_did(&mut self, did: &Did) -> bool { 79 - self.wanted_dids.remove(did) 80 - } 81 - 82 - /// Get the normalized maximum message size. 83 - #[must_use] 84 - pub fn max_message_size(&self) -> i64 { 85 - normalize_max_message_size(self.max_message_size_bytes) 86 - } 87 - 88 - /// Construct the Jetstream subscribe URL, returning a tuple of the URL and a boolean 89 - /// indicating whether the client should send an options update message on connect. 90 - #[must_use] 91 - pub fn subscribe_url(&self, url: &url::Url) -> (url::Url, bool) { 92 - let mut url = url.to_owned(); 93 - url.set_path("/subscribe"); 94 - url.set_query(None); 95 - 96 - if let Some(cursor) = self.cursor { 97 - url.query_pairs_mut() 98 - .append_pair("cursor", &cursor.to_string()); 99 - } 100 - 101 - if self.subscribe_url_len(&url) > MAX_URL_LENGTH { 102 - url.query_pairs_mut().append_pair("requireHello", "true"); 103 - return (url, true); 104 - } 105 - 106 - if !self.wanted_dids.is_empty() || !self.wanted_collections.is_empty() { 107 - let mut query = url.query_pairs_mut(); 108 - for collection in &self.wanted_collections { 109 - query.append_pair("wantedCollections", collection); 110 - } 111 - for did in &self.wanted_dids { 112 - query.append_pair("wantedDids", did.as_str()); 113 - } 114 - } 115 - 116 - if self.max_message_size() > 0 { 117 - url.query_pairs_mut().append_pair( 118 - "maxMessageSizeBytes", 119 - &self.max_message_size_bytes.to_string(), 120 - ); 121 - } 122 - 123 - (url, false) 124 - } 125 - 126 - /// Present the `SubscriberOptions` as a [`SubscriberSourcedMessage`] for serialization. 127 - #[must_use] 128 - pub fn as_subscriber_sourced_message(&self) -> SubscriberSourcedMessage<'_> { 129 - SubscriberSourcedMessage::OptionsUpdate(self.into()) 130 - } 131 - 132 - fn subscribe_url_len(&self, base: &url::Url) -> usize { 133 - const WANTED_DIDS_LEN: usize = "wantedDids=".len(); 134 - const WANTED_COLLECTIONS_LEN: usize = "wantedCollections=".len(); 135 - 136 - let (wanted_did_len, wanted_dids_count) = 137 - self.wanted_dids.iter().fold((0, 0), |(len, count), val| { 138 - (len + WANTED_DIDS_LEN + val.len(), count + 1) 139 - }); 140 - 141 - let (wanted_col_len, wanted_col_count) = self 142 - .wanted_collections 143 - .iter() 144 - .fold((0, 0), |(len, count), val| { 145 - (len + WANTED_COLLECTIONS_LEN + val.len(), count + 1) 146 - }); 147 - 148 - let (message_size_len, message_size_count) = match self.max_message_size() { 149 - 0 => (0, 0), 150 - n => (n.to_string().len() + "maxMessageSizeBytes=".len(), 1), 151 - }; 152 - 153 - let param_count = wanted_dids_count + wanted_col_count + message_size_count; 154 - base.as_str().len() + message_size_len + wanted_did_len + wanted_col_len + param_count 155 - } 156 - } 157 - 158 - mod max_message_size { 159 - use serde::{Deserialize, Deserializer, Serializer}; 160 - 161 - pub fn deserialize<'de, D>(deserializer: D) -> Result<i64, D::Error> 162 - where 163 - D: Deserializer<'de>, 164 - { 165 - let value = <i64 as Deserialize>::deserialize(deserializer)?; 166 - Ok(super::normalize_max_message_size(value)) 167 - } 168 - 169 - pub fn serialize<S>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> 170 - where 171 - S: Serializer, 172 - { 173 - serializer.serialize_i64(super::normalize_max_message_size(*value)) 174 - } 175 - } 176 - 177 - const fn normalize_max_message_size(value: i64) -> i64 { 178 - value.abs() 179 - } 180 - 181 - /// Subscriber sourced message. 182 - /// 183 - /// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#subscriber-sourced-messages> 184 - /// 185 - #[derive(Debug, Serialize)] 186 - #[serde(tag = "type", content = "payload", rename_all = "snake_case")] 187 - pub enum SubscriberSourcedMessage<'a> { 188 - OptionsUpdate(OptionsUpdate<'a>), 189 - } 190 - 191 - impl SubscriberSourcedMessage<'_> { 192 - /// Serialize the [`SubscriberSourcedMessage`] to JSON. 193 - #[must_use] 194 - pub fn to_json(&self) -> String { 195 - serde_json::to_string(self).expect("SubscriberSourcedMessage should be serializable") 196 - } 197 - } 198 - 199 - #[derive(Debug, Serialize)] 200 - #[serde(rename_all = "camelCase")] 201 - pub struct OptionsUpdate<'a> { 202 - wanted_collections: &'a HashSet<Box<Nsid>>, 203 - wanted_dids: &'a HashSet<OwnedDid>, 204 - #[serde(with = "max_message_size")] 205 - max_message_size_bytes: &'a i64, 206 - } 207 - 208 - impl<'a> From<&'a SubscriberOptions> for OptionsUpdate<'a> { 209 - fn from(value: &'a SubscriberOptions) -> Self { 210 - let SubscriberOptions { 211 - wanted_collections, 212 - wanted_dids, 213 - max_message_size_bytes, 214 - cursor: _, 215 - } = value; 216 - Self { 217 - wanted_collections, 218 - wanted_dids, 219 - max_message_size_bytes, 220 - } 221 - } 222 - } 223 - 224 - #[cfg(test)] 225 - mod tests { 226 - use std::collections::HashSet; 227 - 228 - use atproto::{Did, Nsid}; 229 - 230 - use super::SubscriberOptions; 231 - 232 - #[test] 233 - fn default() { 234 - let base = "wss://jetstream1.us-east.bsky.network".parse().unwrap(); 235 - let options = SubscriberOptions::default(); 236 - let (url, _) = options.subscribe_url(&base); 237 - assert_eq!( 238 - url.as_str(), 239 - "wss://jetstream1.us-east.bsky.network/subscribe" 240 - ); 241 - } 242 - 243 - #[test] 244 - fn one_collection() { 245 - let base = "wss://jetstream1.us-east.bsky.network".parse().unwrap(); 246 - let mut options = SubscriberOptions::default(); 247 - options 248 - .add_collection(Nsid::from_static("app.bsky.feed.like").into_boxed()) 249 - .unwrap(); 250 - let (url, _) = options.subscribe_url(&base); 251 - assert_eq!( 252 - url.as_str(), 253 - "wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.like" 254 - ); 255 - } 256 - 257 - #[test] 258 - fn query_len() { 259 - let url: url::Url = "wss://example.url/subscribe".parse().unwrap(); 260 - let mut options = SubscriberOptions::default(); 261 - assert_eq!( 262 - options.subscribe_url_len(&url), 263 - "wss://example.url/subscribe".len() 264 - ); 265 - 266 - options 267 - .add_collection(Nsid::from_static("sh.tangled.*").into_boxed()) 268 - .unwrap(); 269 - 270 - assert_eq!( 271 - options.subscribe_url_len(&url), 272 - "wss://example.url/subscribe?wantedCollections=sh.tangled.*".len() 273 - ); 274 - 275 - options 276 - .add_collection(Nsid::from_static("app.bsky.*").into_boxed()) 277 - .unwrap(); 278 - assert_eq!( 279 - options.subscribe_url_len(&url), 280 - "wss://example.url/subscribe?wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*".len() 281 - ); 282 - 283 - options.max_message_size_bytes = 1_000_000; 284 - assert_eq!( 285 - options.subscribe_url_len(&url), 286 - "wss://example.url/subscribe?wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*&maxMessageSizeBytes=1000000".len() 287 - ); 288 - } 289 - 290 - #[test] 291 - fn serialize_default_options() { 292 - let options = SubscriberOptions::default(); 293 - let serialized = options.as_subscriber_sourced_message().to_json(); 294 - assert_eq!( 295 - serialized, 296 - r#"{"type":"options_update","payload":{"wantedCollections":[],"wantedDids":[],"maxMessageSizeBytes":0}}"# 297 - ); 298 - } 299 - 300 - #[test] 301 - fn serialize_example_options() { 302 - let options = SubscriberOptions { 303 - wanted_collections: HashSet::from_iter([ 304 - Nsid::from_static("app.bsky.feed.post").into_boxed() 305 - ]), 306 - wanted_dids: HashSet::from_iter([ 307 - Did::from_static("did:plc:q6gjnaw2blty4crticxkmujt").to_owned() 308 - ]), 309 - max_message_size_bytes: 1000000, 310 - ..Default::default() 311 - }; 312 - let serialized = options.as_subscriber_sourced_message().to_json(); 313 - assert_eq!( 314 - serialized, 315 - r#"{"type":"options_update","payload":{"wantedCollections":["app.bsky.feed.post"],"wantedDids":["did:plc:q6gjnaw2blty4crticxkmujt"],"maxMessageSizeBytes":1000000}}"# 316 - ) 317 - } 318 - }
crates/jetstream/src/task.rs crates/gordian-jetstream/src/task.rs
-65
crates/knot/Cargo.toml
··· 1 - [package] 2 - name = "knot" 3 - description = "An alternative Tangled knot-server" 4 - version.workspace = true 5 - authors.workspace = true 6 - repository.workspace = true 7 - license.workspace = true 8 - edition.workspace = true 9 - publish.workspace = true 10 - 11 - [dependencies] 12 - atproto = { workspace = true, features = ["sqlx", "time"] } 13 - auth.workspace = true 14 - identity.workspace = true 15 - jetstream.workspace = true 16 - lexicon.workspace = true 17 - git-service.workspace = true 18 - 19 - anyhow.workspace = true 20 - gix.workspace = true 21 - reqwest.workspace = true 22 - serde.workspace = true 23 - serde_json.workspace = true 24 - thiserror.workspace = true 25 - tracing.workspace = true 26 - url.workspace = true 27 - 28 - aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 29 - axum = { workspace = true, features = ["ws"] } 30 - axum-extra = { version = "0.12.1", features = ["async-read-body"] } 31 - bytes = "1.10.1" 32 - clap = { version = "4.5.47", features = ["derive", "env", "string"] } 33 - data-encoding.workspace = true 34 - futures-util = "0.3.31" 35 - hyper-util = { version = "0.1.17", features = ["client"] } 36 - mimetype-detector = "0.3.4" 37 - moka = { version = "0.12.12", features = ["future"] } 38 - rand = "0.9.2" 39 - rayon = "1.11.0" 40 - rustc-hash = "2.1.1" 41 - time.workspace = true 42 - sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "time", "json", "macros", "derive"] } 43 - tempfile = "3.24.0" 44 - tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] } 45 - tokio-rayon = "2.1.0" 46 - tokio-stream = { version = "0.1.17", features = ["time"] } 47 - tokio-tungstenite = "0.28.0" 48 - tokio-util = "0.7.18" 49 - tower = { version = "0.5.2", features = ["buffer", "filter", "limit"] } 50 - tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing", "util"] } 51 - tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 52 - dashmap = "6.1.0" 53 - mock-pds = { version = "0.0.0", path = "../mock-pds" } 54 - clap_complete = "4.5.65" 55 - 56 - [dev-dependencies] 57 - http-body-util = "0.1.3" 58 - multibase = "0.9.1" 59 - 60 - [target.'cfg(not(target_env = "msvc"))'.dependencies] 61 - tikv-jemallocator = { version = "0.6.1", optional = true } 62 - 63 - [features] 64 - default = ["jemalloc"] 65 - jemalloc = ["dep:tikv-jemallocator"]
-84
crates/knot/README.md
··· 1 - # gordian-knot 2 - 3 - a blazingly fast 🚀 and memory-efficient [knot server](https://tangled.org/tangled.org/core/tree/master/knotserver). 4 - 5 - ⚠️ work in progress. this code is full of jank. 6 - 7 - ## progress 8 - 9 - xrpc api: 10 - 11 - | status | lexicon method | notes | 12 - | :----: | :--------------------------------- | :-- | 13 - | ✅ | `sh.tangled.owner` | | 14 - | ⚫️ | ~~sh.tangled.knot.listKeys~~ | i have no intention of implementing this. | 15 - | ✅ | `sh.tangled.knot.version` | | 16 - | ✅ | `sh.tangled.repo.archive` | | 17 - | ✅ | `sh.tangled.repo.blob` | | 18 - | ✅ | `sh.tangled.repo.branch` | ignores `shortHash` parameter | 19 - | ✅ | `sh.tangled.repo.branches` | | 20 - | 🔶 | `sh.tangled.repo.compare` | seems to work, but only computes `patch` field 😅 | 21 - | ✅ | `sh.tangled.repo.create` | | 22 - | ✅ | `sh.tangled.repo.delete` | | 23 - | ❌ | `sh.tangled.repo.deleteBranch` | | 24 - | ✅ | `sh.tangled.repo.diff` | | 25 - | ❌ | `sh.tangled.repo.forkStatus` | | 26 - | ❌ | `sh.tangled.repo.forkSync` | | 27 - | ✅ | `sh.tangled.repo.getDefaultBranch` | | 28 - | ❌ | `sh.tangled.repo.hiddenRef` | | 29 - | ✅ | `sh.tangled.repo.mergeCheck` | | 30 - | ❌ | `sh.tangled.repo.merge` | | 31 - | ❌ | `sh.tangled.repo.languages` | | 32 - | ✅ | `sh.tangled.repo.log` | | 33 - | ✅ | `sh.tangled.repo.setDefaultBranch` | | 34 - | ✅ | `sh.tangled.repo.tags` | | 35 - | 🔶 | `sh.tangled.repo.tree` | cheats by not computing most recent commit 🚀 | 36 - 37 - jetstream ingest: 38 - 39 - - [x] `sh.tangled.knot.member` 40 - - [x] `sh.tangled.publicKey` 41 - - [x] `sh.tangled.repo` 42 - - [x] `sh.tangled.repo.collaborator` 43 - 44 - `git` services: 45 - 46 - - [x] `git-archive` 47 - - [x] `git-receive-pack` 48 - - [x] `git-upload-pack` 49 - 50 - 51 - `/events`: 52 - 53 - `sh.tangled.repo.refUpdate` events are generated, but pipelines won’t run because i don’t emit any `sh.tangled.repo.pipeline` events. 54 - 55 - `sh.tangled.repo.refUpdate` does not compute language breakdown. 56 - 57 - ## differences to the real knot server 58 - 59 - ### `git` transport 60 - 61 - `ssh` transport for `git` operations is currently unsupported. 62 - 63 - `http` transport is supported for all `git` services (`git fetch`, `git pull`, `git push`, and `git archive`). 64 - 65 - `git push` operations are authorized using signed [service-auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt) tokens, which may be validated against an atproto signing-key or a supported `sh.tangled.publicKey` for the corresponding identity. 66 - 67 - a `git` credential helper is required to generate such tokens. a slightly dodgy one may be found in [crates/credential-helper](../credential-helper), which uses `ssh-agent` to create a signed jwt. 68 - 69 - ### signed git pushes 70 - 71 - `knot` expects all `git push` operations to be cryptographically signed. 72 - 73 - with [tangled](https://tangled.org), `git` operations over `http` are expected to be proxied through an appview to the knot. a sufficiently malicious appview could theoretically modify the content of the push inflight. to prevent inflight modification, `knot` requires pushes to be accompanied by push certificate signed by a key from a `sh.tangled.publicKey` record associated with the authorized identity. 74 - 75 - cf: 76 - - <https://git-scm.com/docs/git-push#documentation/git-push.txt---signed> 77 - - <https://git-scm.com/docs/githooks/2.27.0#pre-receive> 78 - - <https://git-scm.com/docs/git-receive-pack#_pre_receive_hook> 79 - 80 - this requirement can be disabled by the knot operator with the `--require-signed-push=false` argument. 81 - 82 - ### `sh.tangled.repo.delete` 83 - 84 - `knot` doesn’t delete repositories, but instead archives them in a `deleted/` directory.
crates/knot/migrations/20251103141538_init.down.sql crates/gordian-knot/migrations/20251103141538_init.down.sql
crates/knot/migrations/20251103141538_init.up.sql crates/gordian-knot/migrations/20251103141538_init.up.sql
crates/knot/migrations/20260114093520_claims.down.sql crates/gordian-knot/migrations/20260114093520_claims.down.sql
crates/knot/migrations/20260114093520_claims.up.sql crates/gordian-knot/migrations/20260114093520_claims.up.sql
crates/knot/migrations/20260114140028_members.down.sql crates/gordian-knot/migrations/20260114140028_members.down.sql
crates/knot/migrations/20260114140028_members.up.sql crates/gordian-knot/migrations/20260114140028_members.up.sql
crates/knot/migrations/20260114172448_collaborators.down.sql crates/gordian-knot/migrations/20260114172448_collaborators.down.sql
crates/knot/migrations/20260114172448_collaborators.up.sql crates/gordian-knot/migrations/20260114172448_collaborators.up.sql
-285
crates/knot/src/cli.rs
··· 1 - use atproto::did::OwnedDid; 2 - use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; 3 - use clap_complete::Shell; 4 - use core::fmt; 5 - use gix::bstr::BString; 6 - use identity::HttpClient; 7 - use knot::model::config::{DEFAULT_READMES, KnotConfiguration, RepoCacheConfig}; 8 - use std::{env, path::PathBuf, time::Duration}; 9 - use url::Url; 10 - 11 - pub fn parse() -> KnotCommand { 12 - match Arguments::parse().command { 13 - KnotCommand::Generate(arguments) => { 14 - let mut command = Arguments::command(); 15 - let name = command.get_name().to_string(); 16 - clap_complete::generate(arguments.shell, &mut command, name, &mut std::io::stdout()); 17 - std::process::exit(0); 18 - } 19 - KnotCommand::Serve(mut arguments) => { 20 - if let Some("") = arguments.archive_bz2_command.as_deref() { 21 - arguments.archive_bz2_command = None; 22 - } 23 - 24 - if let Some("") = arguments.archive_xz_command.as_deref() { 25 - arguments.archive_xz_command = None; 26 - } 27 - 28 - KnotCommand::Serve(arguments) 29 - } 30 - hook @ KnotCommand::Hook(_) => hook, 31 - } 32 - } 33 - 34 - #[derive(Debug, Parser)] 35 - #[command(about, author, version)] 36 - pub struct Arguments { 37 - #[clap(subcommand)] 38 - command: KnotCommand, 39 - } 40 - 41 - #[derive(Debug, Subcommand, Clone)] 42 - pub enum KnotCommand { 43 - Generate(GenerateArguments), 44 - Serve(ServeArguments), 45 - Hook(HookArguments), 46 - } 47 - 48 - /// Generate shell completions. 49 - #[derive(Clone, Debug, Args)] 50 - pub struct GenerateArguments { 51 - shell: Shell, 52 - } 53 - 54 - /// Serve the tangled knot. 55 - #[derive(Clone, Debug, Args)] 56 - pub struct ServeArguments { 57 - /// FQDN of the knot. 58 - #[arg(long, short, value_hint = ValueHint::Hostname, env = "KNOT_NAME")] 59 - #[cfg_attr(debug_assertions, arg(default_value = "localhost:5555"))] 60 - pub name: String, 61 - 62 - /// Handle or DID of the knot owner. 63 - #[arg(long, short, env = "KNOT_OWNER")] 64 - pub owner: OwnedDid, 65 - 66 - /// Base path for repositories. 67 - #[arg(long, short, value_hint = ValueHint::DirPath, env = "KNOT_REPO_BASE")] 68 - #[arg(default_value = default_repository_base().into_os_string())] 69 - pub repos: PathBuf, 70 - 71 - /// Path to knot-level git hooks. 72 - #[arg(long, short = 'H', value_hint = ValueHint::DirPath, env = "KNOT_HOOKS_PATH")] 73 - pub hooks: Option<PathBuf>, 74 - 75 - /// Path to knot-level git config. 76 - #[arg(long, value_hint = ValueHint::FilePath, env = "KNOT_GIT_CONFIG_PATH")] 77 - #[arg(default_value = default_repository_base().join("git_config").into_os_string())] 78 - pub git_config: PathBuf, 79 - 80 - /// Address to bind the the public knot API. 81 - #[arg(long, value_delimiter = ',', env = "KNOT_ADDR")] 82 - #[arg(default_value = "localhost:5555")] 83 - pub bind: Vec<String>, 84 - 85 - /// Path to the knot sqlite database. 86 - #[arg(long, env = "KNOT_DATABASE_PATH", default_value = "knot.db")] 87 - pub db: PathBuf, 88 - 89 - /// PLC directory for DID resolution. 90 - #[arg(long, value_hint = ValueHint::Url, env = "KNOT_PLC_DIRECTORY")] 91 - #[arg(default_value = "https://plc.directory")] 92 - pub plc_directory: String, 93 - 94 - #[arg(long, short, value_delimiter = ',', value_hint = ValueHint::Url, env = "KNOT_JETSTREAM")] 95 - #[arg(default_value = default_jetstream_instances())] 96 - pub jetstream: Vec<String>, 97 - 98 - /// Acceptable authorization methods for git pushes over http. 99 - #[arg(hide = true, long, require_equals = true, value_delimiter = ',')] 100 - #[arg(env = "KNOT_AUTH_METHODS")] 101 - #[arg(default_value = "service-auth,public-key")] 102 - pub auth_methods: Vec<AuthenticationMethods>, 103 - 104 - /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'. 105 - /// 106 - /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed> 107 - #[arg(long, action = ArgAction::Set, require_equals = true)] 108 - #[arg(default_value_t = true)] 109 - pub require_signed_push: bool, 110 - 111 - /// Number of open repository handles to cache. 112 - /// 113 - /// Keeping open handles reduces the overhead of opening a repository at the 114 - /// expense of increased memory usage. 115 - #[arg(long, env = "KNOT_REPO_CACHE_SIZE", default_value_t = 0)] 116 - pub repo_cache_size: u64, 117 - 118 - /// Seconds to retain an idle repository handle in cache. 119 - #[arg(long, env = "KNOT_REPO_CACHE_IDLE", default_value_t = 60)] 120 - pub repo_cache_idle: u64, 121 - 122 - /// Seconds to retain a repository handle in cache. 123 - #[arg(long, env = "KNOT_REPO_CACHE_LIVE", default_value_t = 600)] 124 - pub repo_cache_live: u64, 125 - 126 - /// Command to use to compress bzip2 archives. 127 - #[arg(long, env = "KNOT_ARCHIVE_BZ2", default_value = find_command("bzip2").unwrap_or_default())] 128 - pub archive_bz2_command: Option<String>, 129 - 130 - /// Command to use to compress xz archives. 131 - #[arg(long, env = "KNOT_ARCHIVE_XZ", default_value = find_command("xz").unwrap_or_default())] 132 - pub archive_xz_command: Option<String>, 133 - } 134 - 135 - fn find_command(name: &str) -> Option<String> { 136 - use std::process::Command; 137 - 138 - let output = Command::new("which").arg(name).output().ok()?; 139 - if !output.status.success() { 140 - return None; 141 - } 142 - 143 - let full_path = String::from_utf8(output.stdout).ok()?; 144 - Some(full_path.trim().to_string()) 145 - } 146 - 147 - impl ServeArguments { 148 - pub fn to_knot_config(&self) -> Result<KnotConfiguration, Error> { 149 - let Self { 150 - name, 151 - owner, 152 - repos: repo_path, 153 - hooks: _, 154 - git_config, 155 - bind: _, 156 - db: _, 157 - plc_directory: _, 158 - jetstream: _, 159 - auth_methods: _, 160 - require_signed_push: _, 161 - repo_cache_size, 162 - repo_cache_idle, 163 - repo_cache_live, 164 - archive_bz2_command: _, 165 - archive_xz_command: _, 166 - } = self.clone(); 167 - 168 - // @TODO Validate? 169 - 170 - let instance = format!("did:web:{name}").parse()?; 171 - 172 - Ok(KnotConfiguration { 173 - owner, 174 - instance, 175 - repo_path, 176 - git_config, 177 - readmes: DEFAULT_READMES 178 - .iter() 179 - .map(|v| BString::new(v.to_vec())) 180 - .collect(), 181 - repo_cache: RepoCacheConfig { 182 - size: repo_cache_size, 183 - idle: Duration::from_secs(repo_cache_idle), 184 - live: Duration::from_secs(repo_cache_live), 185 - }, 186 - }) 187 - } 188 - 189 - pub fn init_resolver(&self, http: HttpClient) -> identity::Resolver { 190 - let plc_url = Url::parse(&self.plc_directory).expect("PLC directory should be a valid URL"); 191 - assert!(["http", "https"].contains(&plc_url.scheme())); 192 - 193 - identity::Resolver::builder() 194 - .plc_directory(self.plc_directory.clone()) 195 - .build_with(http) 196 - } 197 - } 198 - 199 - #[derive(Debug, thiserror::Error)] 200 - pub enum Error { 201 - #[error("unable to build 'did:web:{{name}}' from knot fqdn: {0}")] 202 - Name(#[from] atproto::did::Error), 203 - } 204 - 205 - #[derive(Clone, Debug, ValueEnum)] 206 - pub enum AuthenticationMethods { 207 - ServiceAuth, 208 - PublicKey, 209 - } 210 - 211 - fn default_repository_base() -> PathBuf { 212 - env::current_dir().expect("current working directory should be readable") 213 - } 214 - 215 - fn default_jetstream_instances() -> String { 216 - jetstream::PUBLIC_JETSTREAM_INSTANCES.join(",") 217 - } 218 - 219 - /// Forward a git hook to the internal API. 220 - /// 221 - /// This command is expected to be invoked by git during operations via 222 - /// the global hook shims. 223 - #[derive(Clone, Args)] 224 - pub struct HookArguments { 225 - /// Internal API endpoints. 226 - #[arg(long, value_delimiter = ',', env = knot::private::ENV_PRIVATE_ENDPOINTS)] 227 - pub api: Vec<Url>, 228 - 229 - /// DID of the repository owner. 230 - #[arg(long, env = knot::private::ENV_REPO_DID)] 231 - pub repo_did: OwnedDid, 232 - 233 - /// Record key of the repository. 234 - #[arg(long, env = knot::private::ENV_REPO_RKEY)] 235 - pub repo_rkey: String, 236 - 237 - /// Name of the hook to forward. 238 - pub hook: HookName, 239 - } 240 - 241 - impl fmt::Debug for HookArguments { 242 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 243 - f.debug_struct("HookArguments") 244 - // Suppress `url::Url`'s god-awful debug output. 245 - .field("api", &self.api.iter().map(Url::as_str).collect::<Vec<_>>()) 246 - .field("repo_did", &self.repo_did) 247 - .field("repo_rkey", &self.repo_rkey) 248 - .field("hook", &self.hook) 249 - .finish() 250 - } 251 - } 252 - 253 - #[derive(Clone, Copy, Debug, ValueEnum)] 254 - #[clap(rename_all = "kebab-case")] 255 - pub enum HookName { 256 - PreReceive, 257 - PostReceive, 258 - PostUpdate, 259 - } 260 - 261 - impl fmt::Display for HookName { 262 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 263 - f.write_str(match self { 264 - Self::PreReceive => "pre-receive", 265 - Self::PostReceive => "post-receive", 266 - Self::PostUpdate => "post-update", 267 - }) 268 - } 269 - } 270 - 271 - impl AsRef<std::path::Path> for HookName { 272 - fn as_ref(&self) -> &std::path::Path { 273 - std::path::Path::new(match self { 274 - Self::PreReceive => "pre-receive", 275 - Self::PostReceive => "post-receive", 276 - Self::PostUpdate => "post-update", 277 - }) 278 - } 279 - } 280 - 281 - impl HookName { 282 - pub fn iter_variants() -> impl Iterator<Item = HookName> { 283 - [Self::PreReceive, Self::PostReceive, Self::PostUpdate].into_iter() 284 - } 285 - }
crates/knot/src/extractors.rs crates/gordian-knot/src/extractors.rs
-145
crates/knot/src/hooks.rs
··· 1 - use std::{ 2 - collections::HashMap, 3 - env, 4 - fs::{self, Permissions}, 5 - io::{self, Write}, 6 - os::unix::fs::PermissionsExt, 7 - path::Path, 8 - }; 9 - 10 - use axum::http::{HeaderMap, HeaderName, HeaderValue, header::InvalidHeaderName}; 11 - use bytes::Bytes; 12 - use knot::private; 13 - 14 - use crate::cli::{HookArguments, HookName}; 15 - 16 - /// Setup the global hooks directory at `path`. 17 - pub fn setup_global_hooks<P: AsRef<Path>>(path: P) -> io::Result<()> { 18 - let executable = env::current_exe() 19 - .map(|path| path.to_str().map(ToOwned::to_owned)) 20 - .expect("Current executable must be defined") 21 - .expect("Current executable must be valid utf8"); 22 - 23 - let _ = fs::create_dir_all(&path); 24 - for hook_name in HookName::iter_variants() { 25 - let hook_path = path.as_ref().join(hook_name); 26 - let script = format!( 27 - "#!/usr/bin/sh\n# This file is generated by gordian-knot. Do not modify.\n{executable} hook {hook_name}\n" 28 - ); 29 - std::fs::write(&hook_path, script)?; 30 - 31 - let permissions = Permissions::from_mode(0o755); 32 - std::fs::set_permissions(&hook_path, permissions)?; 33 - tracing::info!(?executable, ?hook_path, "git hook installed"); 34 - } 35 - Ok(()) 36 - } 37 - 38 - /// [`core::fmt::Debug`] an [`url::Url`] without causing eye-cancer. 39 - #[repr(transparent)] 40 - struct DebugUrl(url::Url); 41 - 42 - impl core::fmt::Debug for DebugUrl { 43 - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 44 - core::fmt::Display::fmt(&self.0, f) 45 - } 46 - } 47 - 48 - /// [`core::fmt::Debug`] a slice [`url::Url`] without causing eye-cancer. 49 - pub struct DebugUrls<'a>(pub &'a [url::Url]); 50 - 51 - impl<'a> core::fmt::Debug for DebugUrls<'a> { 52 - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 53 - let urls = unsafe { 54 - // SAFETY: Close your eyes an pray! 55 - &*(self.0 as *const [url::Url] as *const [DebugUrl]) 56 - }; 57 - core::fmt::Debug::fmt(&urls, f) 58 - } 59 - } 60 - 61 - #[tracing::instrument(fields(api = ?DebugUrls(&api)))] 62 - pub async fn run_hook( 63 - HookArguments { 64 - api, 65 - repo_did, 66 - repo_rkey, 67 - hook, 68 - }: HookArguments, 69 - ) -> anyhow::Result<()> { 70 - if api.is_empty() { 71 - tracing::warn!("internal API not specified, skipping hook"); 72 - return Ok(()); 73 - }; 74 - 75 - let mut environment_vars: HashMap<_, _> = env::vars() 76 - .filter(|(key, _)| !key.trim().is_empty()) 77 - .collect(); 78 - 79 - let request_id = take_var(&mut environment_vars, "X_REQUEST_ID").ok(); 80 - 81 - // Build a header map with the remaining environment variables. 82 - let mut headers = HeaderMap::with_capacity(environment_vars.len()); 83 - if let Some(request_id) = request_id { 84 - headers.insert("X-Request-ID", HeaderValue::from_str(&request_id)?); 85 - } 86 - 87 - for (key, value) in environment_vars { 88 - match (variable_to_header_name(&key), HeaderValue::try_from(&value)) { 89 - (Ok(key), Ok(value)) => _ = headers.insert(key, value), 90 - (Err(error), _) => tracing::warn!(?error, ?key, ?value, "ignoring header"), 91 - (_, Err(error)) => tracing::warn!(?error, ?key, ?value, "ignoring header"), 92 - } 93 - } 94 - 95 - let stdin = Bytes::from(io::read_to_string(io::stdin())?); 96 - 97 - let client = reqwest::Client::new(); 98 - let url_path = format!("/hook/{repo_did}/{repo_rkey}/{hook}"); 99 - for mut hook_url in api { 100 - hook_url.set_path(&url_path); 101 - let response = client 102 - .post(hook_url) 103 - .headers(headers.clone()) 104 - .body(stdin.clone()) 105 - .send() 106 - .await; 107 - 108 - match response { 109 - Ok(response) if response.status().is_success() => { 110 - let body = response.bytes().await?; 111 - io::stdout().write_all(&body)?; 112 - return Ok(()); 113 - } 114 - Ok(response) => { 115 - let status = response.status(); 116 - let body = response.bytes().await?; 117 - io::stdout().write_all(&body)?; 118 - return Err(anyhow::anyhow!("Knot returned error status {status}")); 119 - } 120 - Err(error) => { 121 - tracing::error!(?error, "failed to post hook to internal API"); 122 - continue; 123 - } 124 - } 125 - } 126 - 127 - Err(anyhow::anyhow!("Failed to find a valid internal endpoint")) 128 - } 129 - 130 - fn take_var(vars: &mut HashMap<String, String>, name: &str) -> anyhow::Result<String> { 131 - vars.remove(name).ok_or(anyhow::anyhow!( 132 - "Expected environment variable {name:?} to be set", 133 - )) 134 - } 135 - 136 - fn variable_to_header_name(name: &str) -> Result<HeaderName, InvalidHeaderName> { 137 - format!( 138 - "{}-{}", 139 - private::ENV_HEADER_PREFIX, 140 - name.trim_start_matches("GORDIAN_") 141 - ) 142 - .replace('_', "-") 143 - .to_lowercase() 144 - .try_into() 145 - }
-684
crates/knot/src/lib.rs
··· 1 - use std::io; 2 - 3 - use atproto::Nsid; 4 - use axum::Router; 5 - use tokio::{net::TcpListener, task::JoinSet}; 6 - use tokio_util::sync::CancellationToken; 7 - 8 - pub mod extractors; 9 - pub mod model; 10 - pub mod private; 11 - pub mod public; 12 - pub mod services; 13 - pub mod sync; 14 - pub mod types; 15 - mod util; 16 - 17 - #[cfg(test)] 18 - pub(crate) mod mock; 19 - 20 - pub mod nsid { 21 - use atproto::Nsid; 22 - 23 - macro_rules! nsid { 24 - ($nsid:literal) => { 25 - unsafe { Nsid::from_static_unchecked($nsid) } 26 - }; 27 - } 28 - 29 - pub const SH_TANGLED_KNOT_MEMBER: &Nsid = nsid!("sh.tangled.knot.member"); 30 - pub const SH_TANGLED_PUBLICKEY: &Nsid = nsid!("sh.tangled.publicKey"); 31 - pub const SH_TANGLED_REPO: &Nsid = nsid!("sh.tangled.repo"); 32 - pub const SH_TANGLED_REPO_COLLABORATOR: &Nsid = nsid!("sh.tangled.repo.collaborator"); 33 - pub const SH_TANGLED_REPO_CREATE: &Nsid = nsid!("sh.tangled.repo.create"); 34 - pub const SH_TANGLED_REPO_DELETE: &Nsid = nsid!("sh.tangled.repo.delete"); 35 - pub const SH_TANGLED_REPO_GITRECEIVEPACK: &Nsid = nsid!("sh.tangled.repo.gitReceivePack"); 36 - pub const SH_TANGLED_REPO_SETDEFAULTBRANCH: &Nsid = nsid!("sh.tangled.repo.setDefaultBranch"); 37 - } 38 - 39 - /// NSIDs of interest to a knot server. 40 - pub const NSIDS: &[&Nsid] = { 41 - &[ 42 - nsid::SH_TANGLED_KNOT_MEMBER, 43 - nsid::SH_TANGLED_PUBLICKEY, 44 - nsid::SH_TANGLED_REPO, 45 - nsid::SH_TANGLED_REPO_COLLABORATOR, 46 - ] 47 - }; 48 - 49 - pub async fn serve_all( 50 - router: Router, 51 - listeners: impl IntoIterator<Item = TcpListener>, 52 - shutdown: CancellationToken, 53 - ) -> io::Result<()> { 54 - let mut service = JoinSet::new(); 55 - for listener in listeners { 56 - let router = router.clone(); 57 - let addr = listener.local_addr()?; 58 - tracing::info!(?addr, "listening on socket"); 59 - 60 - let shutdown = shutdown.child_token(); 61 - service.spawn(async move { 62 - axum::serve(listener, router) 63 - .with_graceful_shutdown(async move { shutdown.cancelled().await }) 64 - .await 65 - }); 66 - } 67 - 68 - for task in service.join_all().await { 69 - task?; 70 - } 71 - 72 - Ok(()) 73 - } 74 - 75 - #[cfg(test)] 76 - mod tests { 77 - use atproto::{Did, tid::Tid}; 78 - use auth::jwt::Claims; 79 - 80 - use axum::{ 81 - body::Body, 82 - http::{Request, StatusCode}, 83 - }; 84 - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 85 - use tower::ServiceExt; 86 - 87 - use crate::model::Knot; 88 - 89 - const TEST_DID: &str = "did:plc:65gha4t3avpfpzmvpbwovss7"; 90 - const TEST_INSTANCE: &str = "lib-knot-test"; 91 - 92 - fn get(uri: &str) -> Request<Body> { 93 - Request::builder().uri(uri).body(Body::empty()).unwrap() 94 - } 95 - 96 - #[tokio::test] 97 - async fn can_query_knot_owner() { 98 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 99 - let response = super::public::router() 100 - .with_state(knot) 101 - .oneshot(get("/xrpc/sh.tangled.owner")) 102 - .await 103 - .unwrap(); 104 - 105 - assert_eq!(response.status(), StatusCode::OK); 106 - let body = axum::body::to_bytes(response.into_body(), 1000) 107 - .await 108 - .unwrap(); 109 - 110 - assert_eq!( 111 - body.as_ref(), 112 - format!("{{\"owner\":\"{TEST_DID}\"}}").as_bytes() 113 - ); 114 - 115 - let resp: lexicon::sh_tangled::owner::Output = serde_json::from_slice(&body).unwrap(); 116 - assert_eq!(resp.owner.as_str(), TEST_DID); 117 - } 118 - 119 - #[tokio::test] 120 - async fn xrpc_sh_tangled_repo_missing_repo() { 121 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 122 - for particle in ["tree", "log", "tags", "branches"] { 123 - let response = super::public::router() 124 - .with_state(knot.clone()) 125 - .oneshot(get(&format!("/xrpc/sh.tangled.repo.{particle}"))) 126 - .await 127 - .unwrap(); 128 - 129 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 130 - } 131 - } 132 - 133 - #[tokio::test] 134 - async fn xrpc_sh_tangled_repo_bad_repo_format() { 135 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 136 - for particle in ["tree", "log", "tags", "branches"] { 137 - // Missing repo name 138 - let response = super::public::router() 139 - .with_state(knot.clone()) 140 - .oneshot(get(&format!( 141 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com" 142 - ))) 143 - .await 144 - .unwrap(); 145 - 146 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 147 - 148 - // Bad repo names '..' 149 - 150 - for repo_name in ["", "..", "../../secret-data", ".hidden", "/etc/passwd"] { 151 - let response = super::public::router() 152 - .with_state(knot.clone()) 153 - .oneshot(get(&format!( 154 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/{repo_name}" 155 - ))) 156 - .await 157 - .unwrap(); 158 - 159 - assert_eq!(response.status(), StatusCode::BAD_REQUEST); 160 - } 161 - } 162 - } 163 - 164 - #[tokio::test] 165 - async fn xrpc_sh_tangled_repo_not_found() { 166 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 167 - for particle in ["tree", "log", "tags", "branches"] { 168 - let response = super::public::router() 169 - .with_state(knot.clone()) 170 - .oneshot(get(&format!( 171 - "/xrpc/sh.tangled.repo.{particle}?repo=did:web:example.com/non-existent-repo" 172 - ))) 173 - .await 174 - .unwrap(); 175 - 176 - assert_eq!(response.status(), StatusCode::NOT_FOUND); 177 - } 178 - } 179 - 180 - mod sh_tangled_repo_create { 181 - use crate::nsid::{SH_TANGLED_REPO_CREATE, SH_TANGLED_REPO_DELETE}; 182 - 183 - use super::super::public; 184 - use super::*; 185 - use axum::http::{HeaderValue, Method, Response, header}; 186 - 187 - fn make_claims<F>(iss: &Did, aud: &Did, modify_claims: F) -> Claims 188 - where 189 - F: FnOnce(&mut Claims), 190 - { 191 - let jti: [u8; 16] = rand::random(); 192 - let jti = data_encoding::BASE32_NOPAD_VISUAL 193 - .encode(&jti) 194 - .to_lowercase(); 195 - 196 - let mut claims = Claims { 197 - iss: iss.into(), 198 - aud: aud.into(), 199 - iat: OffsetDateTime::now_utc().unix_timestamp(), 200 - exp: OffsetDateTime::now_utc().unix_timestamp() + 10, 201 - lxm: None, 202 - jti: jti.into(), 203 - }; 204 - 205 - modify_claims(&mut claims); 206 - claims 207 - } 208 - 209 - async fn service_auth_with<F>( 210 - pds: &mock_pds::Pds, 211 - iss: &Did, 212 - aud: &Did, 213 - modify_claims: F, 214 - ) -> HeaderValue 215 - where 216 - F: FnOnce(&mut Claims), 217 - { 218 - let claims = make_claims(iss, aud, modify_claims); 219 - let authorization = pds.service_auth(&claims).await; 220 - HeaderValue::from_str(&authorization).unwrap() 221 - } 222 - 223 - #[tokio::test] 224 - async fn reject_wrong_method() { 225 - let (_, _, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 226 - let response = public::router() 227 - .with_state(knot.clone()) 228 - .oneshot(get("/xrpc/sh.tangled.repo.create")) 229 - .await 230 - .unwrap(); 231 - 232 - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 233 - } 234 - 235 - async fn create_repo_with<F>( 236 - knot: &Knot, 237 - pds: mock_pds::Pds, 238 - did: &Did, 239 - rkey: &str, 240 - repo_name: &str, 241 - source: Option<&str>, 242 - modify_claims: F, 243 - ) -> Response<Body> 244 - where 245 - F: Fn(&mut Claims) + Copy, 246 - { 247 - // Create fake PDS record for our new repository. 248 - pds.insert_record( 249 - did, 250 - "sh.tangled.repo", 251 - rkey, 252 - &serde_json::json!({ 253 - "name": repo_name, 254 - "knot": knot.instance_ident(), 255 - "source": source, 256 - "createdAt": OffsetDateTime::now_utc().format(&Rfc3339).unwrap() 257 - }), 258 - ) 259 - .await; 260 - 261 - // Generate the body of the 'sh.tangled.repo.create' request. 262 - let create = lexicon::sh_tangled::repo::create::Input { 263 - rkey: rkey.to_string(), 264 - default_branch: Some("main".into()), 265 - source: None, 266 - }; 267 - 268 - let auth = service_auth_with(&pds, &did, &knot.instance, |claims| { 269 - claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 270 - modify_claims(claims); 271 - }) 272 - .await; 273 - 274 - let response = public::router() 275 - .with_state(knot.clone()) 276 - .oneshot( 277 - Request::post("/xrpc/sh.tangled.repo.create") 278 - .header(header::AUTHORIZATION, auth) 279 - .header(header::CONTENT_TYPE, "application/json") 280 - .body(Body::new(serde_json::to_string(&create).unwrap())) 281 - .expect("sh.tangled.repo.create request"), 282 - ) 283 - .await 284 - .expect("xrpc response"); 285 - 286 - response 287 - } 288 - 289 - async fn create_repo( 290 - knot: &Knot, 291 - pds: mock_pds::Pds, 292 - did: &Did, 293 - rkey: &str, 294 - repo_name: &str, 295 - source: Option<&str>, 296 - ) -> Response<Body> { 297 - create_repo_with(knot, pds, did, rkey, repo_name, source, |_| {}).await 298 - } 299 - 300 - async fn repo_exists_in_db(knot: &Knot, did: &Did, rkey: &str) -> bool { 301 - knot.resolve_repo_key(&crate::types::repository_path::RepositoryPath { 302 - owner: did.into_boxed().into(), 303 - name: rkey.into(), 304 - }) 305 - .await 306 - .is_ok() 307 - } 308 - 309 - #[tokio::test] 310 - async fn can_create_repo() { 311 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 312 - 313 - let did = Did::from_static(TEST_DID); 314 - pds.insert_identity(did, "tjh.dev").await; 315 - knot.add_member( 316 - "", 317 - "", 318 - "", 319 - &lexicon::sh_tangled::knot::Member::new( 320 - &did, 321 - knot.instance_ident(), 322 - OffsetDateTime::now_utc(), 323 - ), 324 - ) 325 - .await 326 - .unwrap(); 327 - 328 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 329 - assert_eq!( 330 - create_repo(&knot, pds, did, &rkey, "test-repo", None) 331 - .await 332 - .status(), 333 - StatusCode::OK 334 - ); 335 - 336 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 337 - } 338 - 339 - #[tokio::test] 340 - async fn can_create_fork_from_at() { 341 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 342 - 343 - let did = Did::from_static(TEST_DID); 344 - pds.insert_identity(did, "tjh.dev").await; 345 - knot.add_member( 346 - "", 347 - "", 348 - "", 349 - &lexicon::sh_tangled::knot::Member::new( 350 - &did, 351 - knot.instance_ident(), 352 - OffsetDateTime::now_utc(), 353 - ), 354 - ) 355 - .await 356 - .unwrap(); 357 - 358 - // Create a record for the repository to fork from. 359 - // <https://pdsls.dev/at://did:plc:65gha4t3avpfpzmvpbwovss7/sh.tangled.repo/3m24udbjajf22#record> 360 - let aturi = pds 361 - .insert_record( 362 - did, 363 - "sh.tangled.repo", 364 - "3m24udbjajf22", 365 - &serde_json::json!({ 366 - "name": "gordian", 367 - "knot": "gordian.tjh.dev", 368 - "createdAt": "2025-10-01T10:45:52Z" 369 - }), 370 - ) 371 - .await; 372 - 373 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 374 - assert_eq!( 375 - create_repo(&knot, pds, did, &rkey, "test-repo", Some(&aturi)) 376 - .await 377 - .status(), 378 - StatusCode::OK 379 - ); 380 - 381 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 382 - } 383 - 384 - #[tokio::test] 385 - async fn can_create_fork_from_http() { 386 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 387 - 388 - let did = Did::from_static(TEST_DID); 389 - pds.insert_identity(did, "tjh.dev").await; 390 - knot.add_member( 391 - "", 392 - "", 393 - "", 394 - &lexicon::sh_tangled::knot::Member::new( 395 - &did, 396 - knot.instance_ident(), 397 - OffsetDateTime::now_utc(), 398 - ), 399 - ) 400 - .await 401 - .unwrap(); 402 - 403 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 404 - let source = 405 - Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpzmvpbwovss7/3m24udbjajf22"); 406 - assert_eq!( 407 - create_repo(&knot, pds, did, &rkey, "test-repo", source) 408 - .await 409 - .status(), 410 - StatusCode::OK 411 - ); 412 - 413 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 414 - } 415 - 416 - #[tokio::test] 417 - async fn can_create_fork_from_http_fail() { 418 - let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 419 - 420 - let did = Did::from_static(TEST_DID); 421 - pds.insert_identity(did, "tjh.dev").await; 422 - knot.add_member( 423 - "", 424 - "", 425 - "", 426 - &lexicon::sh_tangled::knot::Member::new( 427 - &did, 428 - knot.instance_ident(), 429 - OffsetDateTime::now_utc(), 430 - ), 431 - ) 432 - .await 433 - .unwrap(); 434 - 435 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 436 - let source = 437 - Some("https://gordian.tjh.dev/did:plc:65gha4t3avpfpmvpbwovss7/3m24udbjajf22"); 438 - 439 - assert_ne!( 440 - create_repo(&knot, pds, did, &rkey, "test-repo", source) 441 - .await 442 - .status(), 443 - StatusCode::OK 444 - ); 445 - 446 - // Verifiy the repository wasn't created on disk. 447 - assert!( 448 - std::fs::exists(base.path().join(did.as_str()).join(&rkey)).is_ok_and(|val| !val), 449 - ); 450 - 451 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 452 - } 453 - 454 - #[tokio::test] 455 - async fn rejects_if_owner_is_not_a_member() { 456 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 457 - 458 - let did = Did::from_static(TEST_DID); 459 - pds.insert_identity(did, "tjh.dev").await; 460 - 461 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 462 - assert_ne!( 463 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |_| {}) 464 - .await 465 - .status(), 466 - StatusCode::OK, 467 - ); 468 - 469 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 470 - } 471 - 472 - #[tokio::test] 473 - async fn rejects_auth_issued_in_future() { 474 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 475 - 476 - let did = Did::from_static(TEST_DID); 477 - pds.insert_identity(did, "tjh.dev").await; 478 - knot.add_member( 479 - "", 480 - "", 481 - "", 482 - &lexicon::sh_tangled::knot::Member::new( 483 - &did, 484 - knot.instance_ident(), 485 - OffsetDateTime::now_utc(), 486 - ), 487 - ) 488 - .await 489 - .unwrap(); 490 - 491 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 492 - assert_eq!( 493 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 494 - // 495 - claims.iat = OffsetDateTime::now_utc().unix_timestamp() + 60; 496 - }) 497 - .await 498 - .status(), 499 - StatusCode::FORBIDDEN, 500 - "iat > now => should be 403 Forbidden" 501 - ); 502 - 503 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 504 - } 505 - 506 - #[tokio::test] 507 - async fn rejects_auth_expired() { 508 - let (_base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 509 - 510 - let did = Did::from_static(TEST_DID); 511 - pds.insert_identity(did, "tjh.dev").await; 512 - knot.add_member( 513 - "", 514 - "", 515 - "", 516 - &lexicon::sh_tangled::knot::Member::new( 517 - &did, 518 - knot.instance_ident(), 519 - OffsetDateTime::now_utc(), 520 - ), 521 - ) 522 - .await 523 - .unwrap(); 524 - 525 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 526 - assert_eq!( 527 - create_repo_with(&knot, pds, did, &rkey, "test-repo", None, |claims| { 528 - // 529 - claims.exp = OffsetDateTime::now_utc().unix_timestamp() - 1; 530 - }) 531 - .await 532 - .status(), 533 - StatusCode::FORBIDDEN, 534 - "exp < now => should be 403 Forbidden" 535 - ); 536 - } 537 - 538 - #[tokio::test] 539 - async fn can_delete_repo() { 540 - let (base, pds, knot) = crate::mock::setup(TEST_DID, TEST_INSTANCE).await; 541 - 542 - let did = Did::from_static(TEST_DID); 543 - pds.insert_identity(did, "tjh.dev").await; 544 - knot.add_member( 545 - "", 546 - "", 547 - "", 548 - &lexicon::sh_tangled::knot::Member::new( 549 - &did, 550 - knot.instance_ident(), 551 - OffsetDateTime::now_utc(), 552 - ), 553 - ) 554 - .await 555 - .unwrap(); 556 - 557 - let rkey = Tid::from_datetime(OffsetDateTime::now_utc(), 0).to_string(); 558 - let name = "another-test-repo"; 559 - assert_eq!( 560 - create_repo(&knot, pds.clone(), did, &rkey, name, None) 561 - .await 562 - .status(), 563 - StatusCode::OK 564 - ); 565 - 566 - gix::open(base.path().join(did.as_str()).join(&rkey)) 567 - .expect("new repository should exist"); 568 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 569 - 570 - let delete = lexicon::sh_tangled::repo::delete::Input { 571 - did: did.to_owned(), 572 - rkey: rkey.clone(), 573 - name: "another-test-repo".to_string(), 574 - }; 575 - 576 - // First check we cannot delete without auth. 577 - assert_eq!( 578 - public::router() 579 - .with_state(knot.clone()) 580 - .oneshot( 581 - Request::builder() 582 - .method(Method::POST) 583 - .uri("/xrpc/sh.tangled.repo.delete") 584 - .header(header::CONTENT_TYPE, "application/json") 585 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 586 - .expect("sh.tangled.repo.delete request"), 587 - ) 588 - .await 589 - .expect("xrpc response") 590 - .status(), 591 - StatusCode::UNAUTHORIZED 592 - ); 593 - 594 - // Check repository has not been deleted. 595 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 596 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 597 - 598 - // Or with the wrong lxm. 599 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 600 - claims.lxm = Some(SH_TANGLED_REPO_CREATE.into_boxed()); 601 - }) 602 - .await; 603 - 604 - assert_eq!( 605 - public::router() 606 - .with_state(knot.clone()) 607 - .oneshot( 608 - Request::builder() 609 - .method(Method::POST) 610 - .uri("/xrpc/sh.tangled.repo.delete") 611 - .header(header::CONTENT_TYPE, "application/json") 612 - .header(header::AUTHORIZATION, auth) 613 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 614 - .expect("sh.tangled.repo.delete request"), 615 - ) 616 - .await 617 - .expect("xrpc response") 618 - .status(), 619 - StatusCode::FORBIDDEN 620 - ); 621 - 622 - // Check repository has not been deleted. 623 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 624 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 625 - 626 - // Valid auth, empty request body. 627 - // Or with the wrong auth. 628 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 629 - claims.lxm = Some(SH_TANGLED_REPO_DELETE.into_boxed()); 630 - }) 631 - .await; 632 - assert_eq!( 633 - public::router() 634 - .with_state(knot.clone()) 635 - .oneshot( 636 - Request::builder() 637 - .method(Method::POST) 638 - .uri("/xrpc/sh.tangled.repo.delete") 639 - .header(header::CONTENT_TYPE, "application/json") 640 - .header(header::AUTHORIZATION, auth) 641 - .body(Body::empty()) 642 - .expect("sh.tangled.repo.delete request"), 643 - ) 644 - .await 645 - .expect("xrpc response") 646 - .status(), 647 - StatusCode::BAD_REQUEST 648 - ); 649 - 650 - // Check repository has not been deleted. 651 - gix::open(base.path().join(did.as_str()).join(&rkey)).expect("repository should exist"); 652 - assert!(repo_exists_in_db(&knot, &did, &rkey).await); 653 - 654 - // Or with the wrong auth. 655 - let auth = service_auth_with(&pds, &did, &knot.instance(), |claims| { 656 - claims.lxm = Some("sh.tangled.repo.delete".try_into().unwrap()); 657 - }) 658 - .await; 659 - 660 - assert_eq!( 661 - public::router() 662 - .with_state(knot.clone()) 663 - .oneshot( 664 - Request::builder() 665 - .method(Method::POST) 666 - .uri("/xrpc/sh.tangled.repo.delete") 667 - .header(header::CONTENT_TYPE, "application/json") 668 - .header(header::AUTHORIZATION, auth) 669 - .body(Body::new(serde_json::to_string(&delete).unwrap())) 670 - .expect("sh.tangled.repo.delete request"), 671 - ) 672 - .await 673 - .expect("xrpc response") 674 - .status(), 675 - StatusCode::OK 676 - ); 677 - 678 - // Check repository has been deleted. 679 - gix::open(base.path().join(did.as_str()).join(&rkey)) 680 - .expect_err("deleted repository should not exist"); 681 - assert!(!repo_exists_in_db(&knot, &did, &rkey).await); 682 - } 683 - } 684 - }
-290
crates/knot/src/main.rs
··· 1 - mod cli; 2 - mod hooks; 3 - 4 - use anyhow::Context as _; 5 - use axum::http::{Request, Response}; 6 - use futures_util::FutureExt as _; 7 - use knot::{ 8 - model::{Knot, KnotState, config::KnotConfiguration}, 9 - services::database::DataStore, 10 - }; 11 - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 12 - use std::{env, ffi::OsStr, net::ToSocketAddrs as _, time::Duration}; 13 - use tokio::{net::TcpListener, signal::unix::SignalKind, task::JoinSet}; 14 - use tokio::{runtime::Builder, signal}; 15 - use tokio_util::sync::CancellationToken; 16 - use tower::ServiceBuilder; 17 - use tower_http::{ 18 - ServiceBuilderExt as _, 19 - decompression::RequestDecompressionLayer, 20 - request_id::{MakeRequestUuid, RequestId}, 21 - trace::{MakeSpan, OnResponse, TraceLayer}, 22 - }; 23 - use tracing::{Span, field::Empty, level_filters::LevelFilter}; 24 - use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _}; 25 - 26 - #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] 27 - use tikv_jemallocator::Jemalloc; 28 - 29 - #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] 30 - #[global_allocator] 31 - static GLOBAL: Jemalloc = Jemalloc; 32 - 33 - const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 34 - 35 - fn main() -> anyhow::Result<()> { 36 - tracing_subscriber::registry() 37 - .with( 38 - EnvFilter::builder() 39 - .with_default_directive(LevelFilter::INFO.into()) 40 - .from_env_lossy(), 41 - ) 42 - .with( 43 - tracing_subscriber::fmt::layer() 44 - .with_writer(std::io::stderr) 45 - .without_time(), 46 - ) 47 - .init(); 48 - 49 - let runtime = Builder::new_current_thread() 50 - .enable_all() 51 - .build() 52 - .expect("Failed to build runtime"); 53 - 54 - match cli::parse() { 55 - cli::KnotCommand::Generate(_) => unreachable!("Handled by cli module"), 56 - cli::KnotCommand::Serve(arguments) => runtime.block_on(knot_main(arguments)), 57 - cli::KnotCommand::Hook(arguments) => runtime.block_on(hooks::run_hook(arguments)), 58 - } 59 - } 60 - 61 - pub async fn knot_main(arguments: cli::ServeArguments) -> anyhow::Result<()> { 62 - unsafe { env::set_var("GIT_CONFIG_GLOBAL", &arguments.git_config) }; 63 - 64 - let tempdir = tempfile::TempDir::with_prefix("gordian-knot-")?; 65 - let hooks_path = if let Some(path) = &arguments.hooks { 66 - // @TODO Verify hooks exist in the specified path. 67 - tracing::warn!(?path, "assuming existence of hooks at path"); 68 - path.to_path_buf() 69 - } else { 70 - let path = tempdir.path().join("hooks"); 71 - hooks::setup_global_hooks(&path)?; 72 - path 73 - }; 74 - 75 - assert!(git_config_global("core.hooksPath", &hooks_path)?); 76 - assert!(git_config_global("receive.advertisePushOptions", "true")?); 77 - if let Some(command) = &arguments.archive_bz2_command { 78 - assert!(git_config_global("tar.tar.bz2.command", command)?); 79 - } 80 - if let Some(command) = &arguments.archive_xz_command { 81 - assert!(git_config_global("tar.tar.xz.command", command)?); 82 - } 83 - 84 - let database = { 85 - let pool = { 86 - let connect_options = SqliteConnectOptions::new() 87 - .filename(&arguments.db) 88 - .create_if_missing(true) 89 - .foreign_keys(true) 90 - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); 91 - 92 - SqlitePoolOptions::new() 93 - .connect_with(connect_options) 94 - .await? 95 - }; 96 - 97 - sqlx::migrate!().run(&pool).await?; 98 - DataStore::new(pool) 99 - }; 100 - 101 - let public_http = reqwest::ClientBuilder::new() 102 - .timeout(Duration::from_secs(2)) 103 - .user_agent(USER_AGENT) 104 - .http2_keep_alive_while_idle(true) 105 - .https_only(true) 106 - .build() 107 - .context("Failed to build public HTTP client")?; 108 - 109 - let resolver = arguments.init_resolver(public_http.clone()); 110 - 111 - // Bind listeners for the public API. 112 - let mut public_listeners = Vec::with_capacity(arguments.bind.len()); 113 - for addr in &arguments.bind { 114 - for socket in addr.to_socket_addrs()? { 115 - let listener = TcpListener::bind(socket).await?; 116 - public_listeners.push(listener); 117 - } 118 - } 119 - 120 - // Bind listeners for the private API. 121 - let mut private_listeners = Vec::with_capacity(2); 122 - for socket in "localhost:0".to_socket_addrs()? { 123 - let listener = TcpListener::bind(socket).await?; 124 - private_listeners.push(listener); 125 - } 126 - 127 - // The knot needs to know the sockets we've bound the private API. 128 - let private_addrs = private_listeners 129 - .iter() 130 - .map(tokio::net::TcpListener::local_addr) 131 - .collect::<Result<Vec<_>, std::io::Error>>()?; 132 - 133 - tracing::info!(?private_addrs, "bound internal API"); 134 - 135 - let config: KnotConfiguration = arguments.to_knot_config()?; 136 - let knot_state = KnotState::new(config, resolver, public_http, database, &private_addrs)?; 137 - let knot = Knot::from(knot_state); 138 - 139 - // Ensure the knot owner's records are seeded. 140 - knot.seed_owner() 141 - .await 142 - .context("seeding knot owner's records")?; 143 - 144 - let mut tasks = JoinSet::new(); 145 - let shutdown = CancellationToken::new(); 146 - 147 - // Spawn the internal API. 148 - tasks.spawn(knot::serve_all( 149 - knot::private::router() 150 - .layer( 151 - ServiceBuilder::new() 152 - .set_x_request_id(MakeRequestUuid) 153 - .layer( 154 - TraceLayer::new_for_http() 155 - .make_span_with(PrivateHttpSpan) 156 - .on_request(|_: &Request<_>, _: &Span| {}) 157 - .on_response(TraceResponse), 158 - ) 159 - .propagate_x_request_id(), 160 - ) 161 - .with_state(knot.clone()), 162 - private_listeners, 163 - shutdown.child_token(), 164 - )); 165 - 166 - // Spawn the jetstream consumer. 167 - tasks.spawn( 168 - knot::services::jetstream::init_consumer( 169 - &knot, 170 - arguments.jetstream.as_slice(), 171 - shutdown.child_token(), 172 - ) 173 - .map(|_| Ok(())), 174 - ); 175 - 176 - // Build the public API. 177 - let router = knot::public::router() 178 - .layer(RequestDecompressionLayer::new()) 179 - .layer( 180 - ServiceBuilder::new() 181 - .set_x_request_id(MakeRequestUuid) 182 - .layer( 183 - TraceLayer::new_for_http() 184 - .make_span_with(PublicHttpSpan) 185 - .on_request(|_: &Request<_>, _: &Span| {}) 186 - .on_response(TraceResponse), 187 - ) 188 - .propagate_x_request_id(), 189 - ) 190 - .with_state(knot); 191 - 192 - tasks.spawn(knot::serve_all( 193 - router, 194 - public_listeners, 195 - shutdown.child_token(), 196 - )); 197 - 198 - tasks.spawn(wait_for_shutdown(shutdown)); 199 - 200 - for task in tasks.join_all().await { 201 - if let Err(error) = task { 202 - tracing::error!(?error, "knot task completed with error"); 203 - } 204 - } 205 - 206 - Ok(()) 207 - } 208 - 209 - async fn wait_for_shutdown(shutdown: CancellationToken) -> std::io::Result<()> { 210 - let mut sigterm = signal::unix::signal(SignalKind::terminate())?; 211 - 212 - tokio::select! { 213 - Ok(()) = signal::ctrl_c() => { 214 - eprintln!(); 215 - tracing::info!("ctrl+c received, shutting down ..."); 216 - }, 217 - Some(()) = sigterm.recv() => { 218 - tracing::info!("SIGTERM received, shutting down ..."); 219 - } 220 - } 221 - 222 - shutdown.cancel(); 223 - 224 - Ok(()) 225 - } 226 - 227 - fn git_config_global<K, V>(key: K, value: V) -> std::io::Result<bool> 228 - where 229 - K: AsRef<OsStr>, 230 - V: AsRef<OsStr>, 231 - { 232 - use std::process::Stdio; 233 - 234 - let success = std::process::Command::new("/usr/bin/git") 235 - .args(["config", "set", "--global"]) 236 - .arg(key) 237 - .arg(value) 238 - .stdout(Stdio::inherit()) 239 - .stderr(Stdio::inherit()) 240 - .spawn()? 241 - .wait()? 242 - .success(); 243 - 244 - Ok(success) 245 - } 246 - 247 - macro_rules! make_span { 248 - ($name:ident, $label:literal) => { 249 - #[derive(Clone)] 250 - struct $name; 251 - 252 - impl<B> MakeSpan<B> for $name { 253 - fn make_span(&mut self, request: &axum::http::Request<B>) -> tracing::Span { 254 - let method = request.method(); 255 - let path = request.uri().path(); 256 - 257 - let span = tracing::error_span!($label, id = Empty, method = Empty, path = Empty); 258 - if let Some(id) = request 259 - .extensions() 260 - .get::<RequestId>() 261 - .and_then(|request_id| request_id.header_value().to_str().ok()) 262 - { 263 - span.record("id", &id); 264 - } 265 - 266 - span.record("method", tracing::field::debug(&method)); 267 - span.record("path", tracing::field::debug(&path)); 268 - 269 - span 270 - } 271 - } 272 - }; 273 - } 274 - 275 - make_span!(PublicHttpSpan, "public"); 276 - make_span!(PrivateHttpSpan, "private"); 277 - 278 - #[derive(Clone)] 279 - pub struct TraceResponse; 280 - 281 - impl<B> OnResponse<B> for TraceResponse { 282 - fn on_response(self, response: &Response<B>, latency: Duration, _: &Span) { 283 - match response.status() { 284 - status if status.is_success() => tracing::trace!(?status, ?latency), 285 - status if status.is_client_error() => tracing::warn!(?status, ?latency), 286 - status if status.is_server_error() => tracing::error!(?status, ?latency), 287 - status => tracing::info!(?status, ?latency), 288 - } 289 - } 290 - }
-35
crates/knot/src/mock.rs
··· 1 - use crate::{ 2 - model::{Knot, config::KnotConfiguration}, 3 - services::database::DataStore, 4 - }; 5 - use atproto::did::OwnedDid; 6 - use identity::Resolver; 7 - 8 - pub async fn setup( 9 - owner_did: &str, 10 - instance_name: &str, 11 - ) -> (tempfile::TempDir, mock_pds::Pds, Knot) { 12 - let base = tempfile::tempdir().expect("temporary directory"); 13 - let pool = sqlx::SqlitePool::connect("sqlite://:memory:") 14 - .await 15 - .unwrap(); 16 - 17 - sqlx::migrate!().run(&pool).await.unwrap(); 18 - 19 - let (pds, listener) = mock_pds::init().await; 20 - let pds_api = mock_pds::router(pds.clone()); 21 - tokio::spawn(async move { 22 - axum::serve(listener, pds_api).await.unwrap(); 23 - }); 24 - 25 - let owner_did = OwnedDid::parse(owner_did).expect("owner DID must be valid"); 26 - let instance = OwnedDid::parse(format!("did:web:{instance_name}")) 27 - .expect("instance name should form a valid DID"); 28 - 29 - let database = DataStore::new(pool); 30 - let resolver = Resolver::new(pds.clone()); 31 - let config = KnotConfiguration::new(owner_did.clone(), instance, base.path()); 32 - let knot = Knot::new(config, resolver, reqwest::Client::new(), database, []).unwrap(); 33 - 34 - (base, pds, knot) 35 - }
-259
crates/knot/src/model.rs
··· 1 - pub mod config; 2 - pub mod convert; 3 - pub mod errors; 4 - pub mod knot_state; 5 - pub mod nicediff; 6 - pub mod repository; 7 - 8 - use core::ops; 9 - use std::{borrow::Cow, ffi::OsString, net::SocketAddr, sync::Arc}; 10 - 11 - use atproto::tid::Tid; 12 - use axum::{ 13 - extract::{FromRef, FromRequestParts, OptionalFromRequestParts}, 14 - http::request::Parts, 15 - }; 16 - use futures_util::future::BoxFuture; 17 - use git_service::{state::GitServiceState, util::SetOptionEnv as _}; 18 - use identity::{HttpClient, Resolver}; 19 - use lexicon::sh_tangled::knot::Member; 20 - use time::OffsetDateTime; 21 - use tokio::process::Command; 22 - 23 - use crate::{ 24 - extractors::request_id::RequestId, 25 - model::{config::KnotConfiguration, repository::TangledRepository}, 26 - private, 27 - public::git::{Error, GitAuthorization}, 28 - services::{ 29 - authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 30 - database::DataStore, 31 - }, 32 - }; 33 - 34 - pub use knot_state::KnotState; 35 - 36 - #[derive(Debug, Clone)] 37 - #[repr(transparent)] 38 - pub struct Knot { 39 - inner: Arc<KnotState>, 40 - } 41 - 42 - impl From<Arc<KnotState>> for Knot { 43 - #[inline] 44 - fn from(inner: Arc<KnotState>) -> Self { 45 - Self { inner } 46 - } 47 - } 48 - 49 - impl FromRef<Knot> for Resolver { 50 - #[inline] 51 - fn from_ref(input: &Knot) -> Self { 52 - input.resolver().clone() 53 - } 54 - } 55 - 56 - impl ops::Deref for Knot { 57 - type Target = KnotState; 58 - #[inline] 59 - fn deref(&self) -> &Self::Target { 60 - &self.inner 61 - } 62 - } 63 - 64 - impl Knot { 65 - pub fn new<'a>( 66 - config: KnotConfiguration, 67 - resolver: Resolver, 68 - http: HttpClient, 69 - database: DataStore, 70 - private_binds: impl IntoIterator<Item = &'a SocketAddr>, 71 - ) -> std::io::Result<Self> { 72 - let inner = KnotState::new(config, resolver, http, database, private_binds)?; 73 - Ok(Self { inner }) 74 - } 75 - 76 - pub async fn add_member( 77 - &self, 78 - rkey: &str, 79 - rev: &str, 80 - cid: &str, 81 - member: &Member<'_>, 82 - ) -> anyhow::Result<()> { 83 - let new_member = self 84 - .database() 85 - .upsert_knot_member(rkey, rev, cid, member) 86 - .await?; 87 - 88 - if new_member { 89 - tracing::info!(member = %member.subject, "new knot member"); 90 - crate::services::seed::public_keys(self, &member.subject).await?; 91 - crate::services::seed::repositories(self, &member.subject).await?; 92 - } 93 - 94 - Ok(()) 95 - } 96 - 97 - pub async fn seed_owner(&self) -> anyhow::Result<()> { 98 - self.add_member( 99 - "", 100 - &Tid::MAX.to_string(), 101 - "", 102 - &Member { 103 - subject: Cow::Borrowed(self.owner()), 104 - domain: Cow::Borrowed(self.instance_ident()), 105 - created_at: OffsetDateTime::now_utc(), 106 - }, 107 - ) 108 - .await 109 - } 110 - } 111 - 112 - impl AuthorizationClaimsStore<auth::jwt::Claims> for Knot { 113 - fn get_unexpired_claims<'a: 'b, 'b>( 114 - &'a self, 115 - jti: &'b str, 116 - now: i64, 117 - ) -> BoxFuture<'b, Result<Option<auth::jwt::Claims>, AuthorizationClaimsStoreError>> { 118 - self.inner.get_unexpired_claims(jti, now) 119 - } 120 - 121 - fn store_claims( 122 - &self, 123 - claims: auth::jwt::Claims, 124 - now: i64, 125 - ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>> { 126 - self.inner.store_claims(claims, now) 127 - } 128 - } 129 - 130 - impl GitServiceState for Knot { 131 - type Rejection = Error; 132 - 133 - async fn init_upload_archive(&self, parts: &mut Parts) -> Result<Command, Self::Rejection> { 134 - let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 135 - let repository = TangledRepository::from_git_request(parts, self).await?; 136 - let mut command = repository.git(); 137 - command 138 - .option_env("X_REQUEST_ID", request_id) 139 - .args(["upload-archive"]) 140 - .arg(repository.path()); 141 - 142 - Ok(command.into()) 143 - } 144 - 145 - async fn init_upload_pack_advertisement( 146 - &self, 147 - parts: &mut Parts, 148 - ) -> Result<tokio::process::Command, Self::Rejection> { 149 - let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 150 - let repository = TangledRepository::from_git_request(parts, self).await?; 151 - let mut command = repository.git(); 152 - command 153 - .option_env("X_REQUEST_ID", request_id) 154 - .args([ 155 - "upload-pack", 156 - "--http-backend-info-refs", 157 - "--stateless-rpc", 158 - "--strict", 159 - "--timeout=10", 160 - ]) 161 - .arg(repository.path()); 162 - 163 - Ok(command.into()) 164 - } 165 - 166 - async fn init_upload_pack( 167 - &self, 168 - parts: &mut Parts, 169 - ) -> Result<tokio::process::Command, Self::Rejection> { 170 - let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 171 - let repository = TangledRepository::from_git_request(parts, self).await?; 172 - let mut command = repository.git(); 173 - command 174 - .option_env("X_REQUEST_ID", request_id) 175 - .args(["upload-pack", "--strict", "--stateless-rpc"]) 176 - .arg(repository.path()); 177 - 178 - Ok(command.into()) 179 - } 180 - 181 - async fn init_receive_pack_advertisement( 182 - &self, 183 - parts: &mut Parts, 184 - ) -> Result<tokio::process::Command, Self::Rejection> { 185 - let GitAuthorization(auth) = GitAuthorization::from_request_parts(parts, self).await?; 186 - let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 187 - let repository = TangledRepository::from_git_request(parts, self).await?; 188 - 189 - if !self.can_push(repository.repository_key(), &auth.iss).await { 190 - tracing::error!(did = %auth.iss, "push denied"); 191 - return Err(Error::forbidden( 192 - self, 193 - format!( 194 - "'{}' does not have permission to push to this repository", 195 - auth.iss 196 - ), 197 - ))?; 198 - } 199 - 200 - let nonce_seed = self.generate_push_seed(repository.repository_key()); 201 - let mut command = repository.git(); 202 - command 203 - .env(private::ENV_USER_DID, auth.iss.as_str()) 204 - .option_env("X_REQUEST_ID", request_id) 205 - .args([ 206 - "-c", 207 - &nonce_seed, 208 - "receive-pack", 209 - "--http-backend-info-refs", 210 - "--stateless-rpc", 211 - ]) 212 - .arg(repository.path()); 213 - 214 - Ok(command.into()) 215 - } 216 - 217 - async fn init_receive_pack( 218 - &self, 219 - parts: &mut Parts, 220 - ) -> Result<tokio::process::Command, Self::Rejection> { 221 - let GitAuthorization(auth) = GitAuthorization::from_request_parts(parts, self).await?; 222 - let request_id = RequestId::from_request_parts(parts, self).await.unwrap(); 223 - let repository = TangledRepository::from_git_request(parts, self).await?; 224 - 225 - if !self.can_push(repository.repository_key(), &auth.iss).await { 226 - tracing::error!(did = %auth.iss, "push denied"); 227 - return Err(Error::forbidden( 228 - self, 229 - format!( 230 - "'{}' does not have permission to push to this repository", 231 - auth.iss 232 - ), 233 - ))?; 234 - } 235 - 236 - let allowed_signers_path = std::env::current_dir() 237 - .unwrap() 238 - .join("allowed_signers") 239 - .join(auth.iss.as_str()); 240 - 241 - let mut allowed_signers_option = OsString::with_capacity( 242 - "gpg.ssh.allowedSignersFile=".len() + allowed_signers_path.as_os_str().len(), 243 - ); 244 - allowed_signers_option.push("gpg.ssh.allowedSignersFile="); 245 - allowed_signers_option.push(&allowed_signers_path); 246 - 247 - let nonce_seed = self.generate_push_seed(repository.repository_key()); 248 - let mut command = repository.git(); 249 - command 250 - .env(private::ENV_USER_DID, auth.iss.as_str()) 251 - .option_env("X_REQUEST_ID", request_id) 252 - .args(["-c", &nonce_seed, "-c"]) 253 - .arg(&allowed_signers_option) 254 - .args(["receive-pack", "--stateless-rpc"]) 255 - .arg(repository.path()); 256 - 257 - Ok(command.into()) 258 - } 259 - }
-98
crates/knot/src/model/config.rs
··· 1 - //! Knot configuration. 2 - use atproto::{Did, did::OwnedDid}; 3 - use gix::bstr::BString; 4 - use rustc_hash::FxHashSet; 5 - use std::{ 6 - path::{Path, PathBuf}, 7 - time::Duration, 8 - }; 9 - 10 - pub const DEFAULT_READMES: &[&[u8]] = &[ 11 - b"README.md", 12 - b"readme.md", 13 - b"README", 14 - b"readme", 15 - b"README.markdown", 16 - b"readme.markdown", 17 - b"README.txt", 18 - b"readme.txt", 19 - b"README.rst", 20 - b"readme.rst", 21 - b"README.org", 22 - b"readme.org", 23 - b"README.asciidoc", 24 - b"readme.asciidoc", 25 - b"index.rst", 26 - ]; 27 - 28 - #[derive(Debug)] 29 - pub struct KnotConfiguration { 30 - pub owner: OwnedDid, 31 - pub instance: OwnedDid, 32 - pub repo_path: PathBuf, 33 - pub git_config: PathBuf, 34 - pub readmes: FxHashSet<BString>, 35 - pub repo_cache: RepoCacheConfig, 36 - } 37 - 38 - #[derive(Default, Debug)] 39 - pub struct RepoCacheConfig { 40 - pub size: u64, 41 - pub idle: Duration, 42 - pub live: Duration, 43 - } 44 - 45 - impl KnotConfiguration { 46 - pub fn new<P>(owner: OwnedDid, instance: OwnedDid, base: P) -> Self 47 - where 48 - P: AsRef<Path>, 49 - { 50 - assert_eq!(instance.method(), "web", "knot instance should be did:web"); 51 - 52 - let base = base.as_ref(); 53 - Self { 54 - owner, 55 - instance, 56 - repo_path: base.to_path_buf(), 57 - git_config: base.join("git_config").to_path_buf(), 58 - readmes: DEFAULT_READMES 59 - .iter() 60 - .map(|readme| BString::new(readme.to_vec())) 61 - .collect(), 62 - repo_cache: RepoCacheConfig::default(), 63 - } 64 - } 65 - 66 - #[inline] 67 - pub fn instance_ident(&self) -> &str { 68 - self.instance().ident() 69 - } 70 - 71 - /// The DID of the knot. 72 - #[inline] 73 - pub fn instance(&self) -> &Did { 74 - assert_eq!(self.instance.method(), "web"); 75 - &self.instance 76 - } 77 - 78 - /// The DID of the knot owner. 79 - #[inline] 80 - pub fn owner(&self) -> &Did { 81 - &self.owner 82 - } 83 - 84 - /// Base path to the knot's repositories. 85 - #[inline] 86 - pub fn repository_base(&self) -> &Path { 87 - self.repo_path.as_path() 88 - } 89 - 90 - #[inline] 91 - pub fn git_config_path(&self) -> &Path { 92 - self.git_config.as_path() 93 - } 94 - 95 - pub fn readmes(&self) -> &FxHashSet<BString> { 96 - &self.readmes 97 - } 98 - }
-153
crates/knot/src/model/convert.rs
··· 1 - use crate::{public::xrpc::XrpcError, types::sh_tangled::repo::tags}; 2 - use data_encoding::BASE64URL; 3 - use gix::bstr::ByteSlice; 4 - use lexicon::sh_tangled::repo::{refs, tree}; 5 - use reqwest::StatusCode; 6 - use std::{borrow::Cow, collections::HashMap}; 7 - use time::{OffsetDateTime, error::ComponentRange}; 8 - 9 - #[derive(Debug, thiserror::Error)] 10 - pub enum ConversionError { 11 - #[error("Failed to decode object: {0}")] 12 - Decode(#[from] gix::objs::decode::Error), 13 - #[error("Failed to parse git time: {0}")] 14 - TimeParse(#[from] gix::error::ParseError), 15 - #[error("Failed to convert git time: {0}")] 16 - TimeConversion(#[from] time::error::ComponentRange), 17 - } 18 - 19 - impl From<ConversionError> for XrpcError { 20 - fn from(value: ConversionError) -> Self { 21 - Self { 22 - status: StatusCode::INTERNAL_SERVER_ERROR, 23 - error: "RepositoryError".into(), 24 - message: value.to_string().into(), 25 - } 26 - } 27 - } 28 - 29 - /// Convert a git timestamp to an [`OffsetDateTime`]. 30 - pub fn time_to_offsetdatetime(time: &gix::date::Time) -> Result<OffsetDateTime, ComponentRange> { 31 - use time::UtcOffset; 32 - 33 - let odt = OffsetDateTime::from_unix_timestamp(time.seconds)? 34 - .to_offset(UtcOffset::from_whole_seconds(time.offset)?); 35 - 36 - Ok(odt) 37 - } 38 - 39 - pub fn try_convert_commit(value: gix::Commit<'_>) -> Result<refs::Commit, ConversionError> { 40 - let id = value.id().detach(); 41 - let decoded = value.decode()?; 42 - let mut merge_tag = String::default(); 43 - let mut extra_headers = HashMap::default(); 44 - for (key, value) in decoded.extra_headers.iter() { 45 - match key.as_bytes() { 46 - b"mergetag" => merge_tag = value.to_string(), 47 - b"gpgsig" => {} 48 - _ => { 49 - extra_headers.insert(key.to_string(), BASE64URL.encode(value.as_bytes())); 50 - } 51 - } 52 - } 53 - 54 - Ok(refs::Commit { 55 - hash: id.into(), 56 - author: try_convert_signature(decoded.author()?)?, 57 - committer: try_convert_signature(decoded.committer()?)?, 58 - merge_tag, 59 - pgp_signature: value.signature()?.map(|(sig, _)| sig.to_string()), 60 - message: decoded.message.to_string(), 61 - tree_hash: value.tree_id()?.detach().into(), 62 - parent_hashes: value.parent_ids().map(|id| id.detach().into()).collect(), 63 - // @TODO Review encoding parameter 64 - encoding: Cow::Borrowed("UTF-8"), 65 - extra_headers, 66 - }) 67 - } 68 - 69 - pub fn convert_entry(value: gix::object::tree::EntryRef<'_, '_>) -> tree::TreeEntry { 70 - use gix::objs::tree::EntryKind; 71 - 72 - let name = value.filename().to_string(); 73 - let mode = value.mode(); 74 - 75 - // Replicate the file mode values that go-git uses. 76 - let mode_string = match mode.kind() { 77 - EntryKind::Tree => "0040000", 78 - EntryKind::Blob => "0100644", 79 - EntryKind::BlobExecutable => "0100755", 80 - EntryKind::Link => "0120000", 81 - EntryKind::Commit => "0160000", 82 - }; 83 - 84 - tree::TreeEntry { 85 - mode: mode_string, 86 - name, 87 - ..Default::default() 88 - } 89 - } 90 - 91 - pub fn convert_reference(value: &gix::Reference<'_>) -> refs::Reference { 92 - refs::Reference { 93 - name: value.name().shorten().to_string(), 94 - hash: value.id().detach().into(), 95 - } 96 - } 97 - 98 - pub fn try_convert_signature( 99 - signature: gix::actor::SignatureRef<'_>, 100 - ) -> Result<refs::Signature, ConversionError> { 101 - let signature = signature.trim(); 102 - Ok(refs::Signature { 103 - name: signature.name.to_string(), 104 - email: signature.email.to_string(), 105 - when: time_to_offsetdatetime(&signature.time()?)?, 106 - }) 107 - } 108 - 109 - impl TryFrom<gix::Tag<'_>> for tags::TagAnnotation { 110 - type Error = gix::objs::decode::Error; 111 - 112 - fn try_from(value: gix::Tag<'_>) -> Result<Self, Self::Error> { 113 - use gix::object::Kind; 114 - 115 - let hash = value.id.into(); 116 - let decoded = value.decode()?; 117 - 118 - // cf. <https://github.com/git/git/blob/7014b55638da979331baf8dc31c4e1d697cf2d67/object.h#L97> 119 - let target_type = match decoded.target_kind { 120 - Kind::Commit => 1, 121 - Kind::Tree => 2, 122 - Kind::Blob => 3, 123 - Kind::Tag => 4, 124 - }; 125 - 126 - Ok(tags::TagAnnotation { 127 - hash, 128 - name: decoded.name.to_string(), 129 - tagger: decoded 130 - .tagger()? 131 - .and_then(|tagger| try_convert_signature(tagger).ok()), 132 - message: decoded.message.to_string(), 133 - pgp_signature: decoded.pgp_signature.map(ToString::to_string), 134 - target_type, 135 - target: decoded.target().into(), 136 - }) 137 - } 138 - } 139 - 140 - impl TryFrom<gix::Reference<'_>> for tags::Tag { 141 - type Error = gix::objs::decode::Error; 142 - 143 - fn try_from(mut value: gix::Reference<'_>) -> Result<Self, Self::Error> { 144 - let r#ref: refs::Reference = convert_reference(&value); 145 - let annotation = value 146 - .peel_to_tag() 147 - .ok() 148 - .map(TryFrom::try_from) 149 - .transpose()?; 150 - 151 - Ok(tags::Tag { r#ref, annotation }) 152 - } 153 - }
crates/knot/src/model/errors.rs crates/gordian-knot/src/model/errors.rs
-527
crates/knot/src/model/knot_state.rs
··· 1 - use std::{ 2 - collections::HashMap, 3 - io::{self, ErrorKind}, 4 - net::SocketAddr, 5 - ops, 6 - path::PathBuf, 7 - process::Stdio, 8 - sync::{Arc, Mutex}, 9 - time::Duration, 10 - }; 11 - 12 - use atproto::{aturi::AtUri, did::Did}; 13 - use futures_util::{FutureExt, future::BoxFuture}; 14 - use identity::{HttpClient, Resolver}; 15 - use lexicon::{ 16 - com::atproto::repo::list_records::Record, 17 - sh_tangled::{git::RefUpdate, repo::Repo}, 18 - }; 19 - use moka::future::{Cache, CacheBuilder}; 20 - use rayon::{ThreadPool, ThreadPoolBuilder}; 21 - use serde::Serialize; 22 - use time::OffsetDateTime; 23 - use tokio::process::Command; 24 - use url::Url; 25 - 26 - use crate::{ 27 - release_or_debug, 28 - services::{ 29 - atrepo, 30 - authorization::{AuthorizationClaimsStore, AuthorizationClaimsStoreError}, 31 - database::{DataStore, DataStoreError}, 32 - }, 33 - types::{ 34 - RecordKey, 35 - repository_key::RepositoryKey, 36 - repository_path::{self, RepositoryPath}, 37 - }, 38 - }; 39 - 40 - use super::config::KnotConfiguration; 41 - 42 - /// Default number of ({handle,did},{name,rkey}) -> (did,rkey) mappings to 43 - /// keep in cache. 44 - const REPO_KEY_CACHE_SIZE: u64 = 16; 45 - 46 - #[derive(Clone, Debug, Serialize)] 47 - #[serde(tag = "$type")] 48 - pub enum Event { 49 - #[serde(rename = "sh.tangled.git.refUpdate")] 50 - RefUpdate(Arc<RefUpdate>), 51 - } 52 - 53 - impl Event { 54 - pub const fn collection(&self) -> &'static str { 55 - match self { 56 - Self::RefUpdate(_) => "sh.tangled.git.refUpdate", 57 - } 58 - } 59 - } 60 - 61 - #[derive(Debug)] 62 - pub struct KnotState { 63 - pub config: KnotConfiguration, 64 - 65 - /// Identity resolver. 66 - resolver: Resolver, 67 - 68 - /// Reqwest client. 69 - /// 70 - // @TODO Wrap this so prevent requests to sensitive/private endpoints. 71 - http: HttpClient, 72 - 73 - database: DataStore, 74 - 75 - /// Thread pool for running synchronous tasks. 76 - pool: ThreadPool, 77 - 78 - events: tokio::sync::broadcast::Sender<(i64, OffsetDateTime, Event)>, 79 - 80 - repo_key_cache: Cache<RepositoryPath, RepositoryKey>, 81 - 82 - repo_cache: Cache<RepositoryKey, gix::ThreadSafeRepository>, 83 - 84 - repo_mutex: Mutex<HashMap<RepositoryKey, Arc<tokio::sync::Mutex<()>>>>, 85 - 86 - push_seed: Mutex<HashMap<RepositoryKey, Box<str>>>, 87 - 88 - private_addrs: String, 89 - } 90 - 91 - impl KnotState { 92 - pub fn new<'a>( 93 - config: KnotConfiguration, 94 - resolver: Resolver, 95 - http: HttpClient, 96 - database: DataStore, 97 - private_binds: impl IntoIterator<Item = &'a SocketAddr>, 98 - ) -> io::Result<Arc<Self>> { 99 - let pool = ThreadPoolBuilder::new() 100 - .build() 101 - .expect("Failed to build thread pool"); 102 - 103 - let (events, _) = tokio::sync::broadcast::channel(16); 104 - 105 - let private_addrs = private_binds 106 - .into_iter() 107 - .map(|socket| format!("http://{socket}/")) 108 - .collect::<Vec<_>>() 109 - .join(","); 110 - 111 - let repo_cache = CacheBuilder::new(config.repo_cache.size) 112 - .name("repository_cache") 113 - .initial_capacity(config.repo_cache.size.try_into().unwrap()) 114 - .time_to_idle(config.repo_cache.idle) 115 - .time_to_live(config.repo_cache.live) 116 - .build(); 117 - 118 - let inner = Arc::new(Self { 119 - config, 120 - http, 121 - resolver, 122 - database, 123 - pool, 124 - events, 125 - repo_key_cache: CacheBuilder::new(REPO_KEY_CACHE_SIZE) 126 - .name("repository_key_cache") 127 - .time_to_idle(Duration::from_secs(60)) 128 - .build(), 129 - repo_cache, 130 - repo_mutex: Default::default(), 131 - push_seed: Default::default(), 132 - private_addrs, 133 - }); 134 - 135 - Ok(inner) 136 - } 137 - 138 - /// Return a reference to the identity resolver. 139 - #[inline] 140 - pub fn resolver(&self) -> &Resolver { 141 - &self.resolver 142 - } 143 - 144 - pub fn http(&self) -> &HttpClient { 145 - &self.http 146 - } 147 - 148 - /// Return a reference the sync thread pool. 149 - #[inline] 150 - pub(crate) fn pool(&self) -> &ThreadPool { 151 - &self.pool 152 - } 153 - 154 - pub(crate) fn subscribe_events( 155 - &self, 156 - ) -> tokio::sync::broadcast::Receiver<(i64, OffsetDateTime, Event)> { 157 - self.events.subscribe() 158 - } 159 - 160 - pub(crate) async fn send_event(&self, id: i64, ts: OffsetDateTime, event: Event) { 161 - if self.events.send((id, ts, event)).is_err() { 162 - tracing::warn!("no external listeners to consume events"); 163 - } 164 - } 165 - 166 - /// Get a reference to the database. 167 - #[inline] 168 - pub fn database(&self) -> &DataStore { 169 - &self.database 170 - } 171 - 172 - pub fn private_endpoints(&self) -> &str { 173 - &self.private_addrs 174 - } 175 - 176 - pub fn get_repo_mutex(&self, repo_key: &RepositoryKey) -> Arc<tokio::sync::Mutex<()>> { 177 - Arc::clone( 178 - self.repo_mutex 179 - .lock() 180 - .expect("mutex should not be poisoned") 181 - .entry(repo_key.clone()) 182 - .or_default(), 183 - ) 184 - } 185 - 186 - /// Resolve a repository path ({handle,did},{rkey,name}) to a repository key (did, rkey). 187 - /// 188 - pub async fn resolve_repo_key( 189 - &self, 190 - repo_path: &RepositoryPath, 191 - ) -> Result<RepositoryKey, RepositoryResolveError> { 192 - use std::borrow::Cow; 193 - 194 - let resolved_repo_path = self 195 - .repo_key_cache 196 - .try_get_with_by_ref(repo_path, async { 197 - let owner = match Did::parse(repo_path.owner()) { 198 - Ok(did) => Cow::Borrowed(did), 199 - Err(_) => { 200 - // Assume the repo owner is a handle. 201 - let (did, _) = self.resolver().resolve(repo_path.owner()).await?; 202 - Cow::Owned(did) 203 - } 204 - }; 205 - 206 - let (rkey, _) = self 207 - .database() 208 - .resolve_repository(&owner, repo_path.name()) 209 - .await? 210 - .ok_or(RepositoryResolveError::NotFound)?; 211 - 212 - let resolved_repo_path = RepositoryKey { 213 - owner: owner.into_owned(), 214 - rkey, 215 - }; 216 - 217 - Ok::<_, RepositoryResolveError>(resolved_repo_path) 218 - }) 219 - .await 220 - .map_err(Arc::unwrap_or_clone)?; 221 - 222 - Ok(resolved_repo_path) 223 - } 224 - 225 - fn path_for_repository(&self, repo_key: &RepositoryKey) -> PathBuf { 226 - self.repository_base() 227 - .join(repo_key.owner_str()) 228 - .join(repo_key.rkey()) 229 - } 230 - 231 - pub async fn open_repository( 232 - &self, 233 - repo_key: &RepositoryKey, 234 - ) -> Result<gix::ThreadSafeRepository, Arc<gix::open::Error>> { 235 - use gix::open::Options; 236 - 237 - let repository = self 238 - .repo_cache 239 - .try_get_with_by_ref(repo_key, async { 240 - let path = self.path_for_repository(repo_key); 241 - tracing::debug!(?path, "opening repository"); 242 - Options::default() 243 - .strict_config(true) 244 - .open_path_as_is(true) 245 - .open(path) 246 - }) 247 - .await?; 248 - 249 - Ok(repository) 250 - } 251 - 252 - pub async fn can_push(&self, repo: &RepositoryKey, did: &Did) -> bool { 253 - use crate::services::rbac::{Action, Policy, PolicyResult, RepositoryPushPolicy}; 254 - let policy = RepositoryPushPolicy; 255 - let result = policy 256 - .evaluate_access(&did, &Action::RepositoryPush, repo, self) 257 - .await; 258 - 259 - matches!(result, PolicyResult::Granted) 260 - } 261 - 262 - pub async fn create_repo(&self, rec: &RecordKey<'_>, repo: &Repo<'_>) -> anyhow::Result<()> { 263 - let &RecordKey { 264 - did, 265 - collection, 266 - rkey, 267 - rev, 268 - cid, 269 - } = rec; 270 - 271 - assert_eq!(collection, "sh.tangled.repo"); 272 - assert_eq!(repo.knot, self.instance_ident()); 273 - 274 - let repo_key = RepositoryKey::new(did, rkey)?; 275 - repository_path::validate(&repo.name)?; 276 - 277 - // We're going to receive the jetstream event and the xrpc request. 278 - // 279 - // If the other is already in progress, wait here. The database insert should return 280 - // Ok(false), and repository creation will be skipped. 281 - let _guard = self.get_repo_mutex(&repo_key).lock_owned().await; 282 - 283 - let mut tx = self.database().begin().await?; 284 - let is_new = tx.insert_repository(did, rkey, rev, cid, repo).await?; 285 - 286 - if !is_new { 287 - return Ok(()); 288 - } 289 - 290 - match &repo.source { 291 - Some(source) => self.fork_repo(&repo_key, &repo.name, source).await?, 292 - None => self.init_repo(&repo_key, &repo.name)?, 293 - } 294 - 295 - tx.commit().await?; 296 - 297 - Ok(()) 298 - } 299 - 300 - pub fn init_repo(&self, repo_key: &RepositoryKey, name: &str) -> anyhow::Result<()> { 301 - repository_path::validate(&repo_key.owner)?; 302 - repository_path::validate(&repo_key.rkey)?; 303 - repository_path::validate(name)?; 304 - 305 - let path = self.path_for_repository(repo_key); 306 - 307 - if let Some(parent) = path.parent() { 308 - match std::fs::create_dir(parent) { 309 - Ok(()) => tracing::info!( 310 - ?parent, 311 - ?repo_key, 312 - "created parent directory for repository" 313 - ), 314 - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} 315 - Err(error) => { 316 - tracing::error!( 317 - ?error, 318 - ?parent, 319 - ?repo_key, 320 - "failed to create parent directory for repository" 321 - ) 322 - } 323 - } 324 - } 325 - 326 - let repo = gix::init_bare(&path)?; 327 - tracing::info!(?repo, "created repository"); 328 - 329 - // Create a symlink to map the repository name -> rkey. 330 - let symlink_path = path 331 - .parent() 332 - .expect("parent for repository path") 333 - .join(name); 334 - 335 - let _ = std::fs::remove_file(&symlink_path); 336 - std::os::unix::fs::symlink(&repo_key.rkey, &symlink_path)?; 337 - 338 - Ok(()) 339 - } 340 - 341 - #[tracing::instrument(skip(self), ret)] 342 - pub async fn fork_repo( 343 - &self, 344 - repo_key: &RepositoryKey, 345 - name: &str, 346 - source: &str, 347 - ) -> anyhow::Result<()> { 348 - // Release build: only clone over https; Debug builds: try https then http. 349 - release_or_debug!(const CLONE_SCHEMES: &[&str] = &["https"], &["https", "http"]); 350 - 351 - let path = self.path_for_repository(repo_key); 352 - tracing::debug!(?path, "forking into"); 353 - 354 - let clone_urls: Vec<_> = match AtUri::parse(source) { 355 - Ok(source_uri) => { 356 - let source_did = source_uri.did().ok_or(anyhow::anyhow!( 357 - "source repository record uri does not contain a did authority " 358 - ))?; 359 - let source_rkey = source_uri.rkey.ok_or(anyhow::anyhow!( 360 - "source repository record uri does not contain a rkey" 361 - ))?; 362 - 363 - // Fetch repository record from pds. 364 - let response = atrepo::fetch_record_bytes( 365 - self.resolver(), 366 - self.http(), 367 - source_did, 368 - "sh.tangled.repo", 369 - source_rkey, 370 - ) 371 - .await?; 372 - 373 - let record = serde_json::from_slice::<Record>(&response)?; 374 - let repo: Repo = serde_json::from_str(record.value.get())?; 375 - 376 - CLONE_SCHEMES 377 - .iter() 378 - .map(|scheme| format!("{scheme}://{}/{source_did}/{}", repo.knot, repo.name)) 379 - .collect() 380 - } 381 - Err(_) => match Url::parse(source) { 382 - Ok(url) if CLONE_SCHEMES.contains(&url.scheme()) => vec![source.to_string()], 383 - _ => return Err(anyhow::anyhow!("Unrecognised URL: {source}")), 384 - }, 385 - }; 386 - 387 - for clone_url in clone_urls { 388 - tracing::info!("forking repo from '{clone_url}'"); 389 - let output = Command::new("/usr/bin/git") 390 - .env_clear() 391 - .args(["clone", "--bare", &clone_url]) 392 - .arg(&path) 393 - .stdout(Stdio::inherit()) 394 - .stderr(Stdio::inherit()) 395 - .output() 396 - .await?; 397 - 398 - if output.status.success() { 399 - // Create a symlink to map the repository name -> rkey. 400 - let symlink_path = path 401 - .parent() 402 - .expect("parent for repository path") 403 - .join(name); 404 - 405 - let _ = std::fs::remove_file(&symlink_path); 406 - let _ = std::os::unix::fs::symlink(&repo_key.rkey, &symlink_path); 407 - 408 - return Ok(()); 409 - } else { 410 - tracing::error!(?clone_url, ?path, "git clone failed"); 411 - } 412 - } 413 - 414 - tracing::error!("all clone attempts failed"); 415 - Err(anyhow::anyhow!("failed to fork repo")) 416 - } 417 - 418 - pub async fn delete_repo(&self, rec: &RecordKey<'_>) -> anyhow::Result<()> { 419 - let mut tx = self.database().begin().await?; 420 - let Some((_, name)) = tx.delete_repository(rec).await? else { 421 - tracing::error!(?rec, "Failed to delete repository"); 422 - return Err(anyhow::anyhow!("Repository does not exist")); 423 - }; 424 - 425 - let path = self.repository_base().join(rec.did.as_str()).join(rec.rkey); 426 - let mut delete_path = self.repository_base().join("deleted"); 427 - let _ = std::fs::create_dir_all(&delete_path); 428 - delete_path.push(format!("{}-{}", rec.did, rec.rkey)); 429 - std::fs::rename(&path, &delete_path)?; 430 - 431 - // Clear from cache. 432 - self.repo_key_cache 433 - .remove(&RepositoryPath::from_parts(rec.did.as_str(), rec.rkey).unwrap()) 434 - .await; 435 - 436 - self.repo_key_cache 437 - .remove(&RepositoryPath::from_parts(rec.did.as_str(), name).unwrap()) 438 - .await; 439 - 440 - tx.commit().await?; 441 - Ok(()) 442 - } 443 - 444 - /// Get or generate a new nonce seed for signed pushes to the specified repository. 445 - pub fn generate_push_seed(&self, repository: &RepositoryKey) -> Box<str> { 446 - const PUSH_SEED_NONCE_LEN: usize = 16; 447 - 448 - let mut push_seeds = self.push_seed.lock().unwrap(); 449 - if let Some(seed) = push_seeds.get(repository).cloned() { 450 - return seed; 451 - } 452 - 453 - let mut option = "receive.certNonceSeed=".to_owned(); 454 - 455 - let raw_seed: [u8; PUSH_SEED_NONCE_LEN] = rand::random(); 456 - data_encoding::BASE32_NOPAD_VISUAL.encode_append(&raw_seed, &mut option); 457 - 458 - let encoded: Box<str> = option.into(); 459 - push_seeds.insert(repository.clone(), encoded.clone()); 460 - encoded 461 - } 462 - } 463 - 464 - #[derive(Clone, Debug, thiserror::Error)] 465 - pub enum RepositoryResolveError { 466 - #[error("Failed to resolve identity: {0}")] 467 - Resolve(#[from] identity::ResolveError), 468 - #[error("Failed to lookup respository: {0}")] 469 - Lookup(Arc<DataStoreError>), 470 - #[error("Repository not found")] 471 - NotFound, 472 - } 473 - 474 - impl From<DataStoreError> for RepositoryResolveError { 475 - fn from(value: DataStoreError) -> Self { 476 - Self::Lookup(Arc::new(value)) 477 - } 478 - } 479 - 480 - impl AuthorizationClaimsStore<auth::jwt::Claims> for KnotState { 481 - fn get_unexpired_claims<'a: 'b, 'b>( 482 - &'a self, 483 - jti: &'b str, 484 - now: i64, 485 - ) -> BoxFuture<'b, Result<Option<auth::jwt::Claims>, AuthorizationClaimsStoreError>> { 486 - async move { 487 - let claims = self.database().get_claims(jti, now).await.ok().flatten(); 488 - 489 - // If the claims have expired, remove them. 490 - if matches!(&claims, Some(claims) if claims.exp < now) { 491 - self.database() 492 - .delete_claims(jti) 493 - .await 494 - .map_err(|error| AuthorizationClaimsStoreError(error.into()))?; 495 - 496 - return Ok(None); 497 - } 498 - 499 - Ok(claims) 500 - } 501 - .boxed() 502 - } 503 - 504 - fn store_claims( 505 - &self, 506 - claims: auth::jwt::Claims, 507 - now: i64, 508 - ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>> { 509 - async move { 510 - self.database() 511 - .store_claims(claims, now) 512 - .await 513 - .map_err(|error| AuthorizationClaimsStoreError(error.into()))?; 514 - 515 - Ok(()) 516 - } 517 - .boxed() 518 - } 519 - } 520 - 521 - impl ops::Deref for KnotState { 522 - type Target = KnotConfiguration; 523 - #[inline] 524 - fn deref(&self) -> &Self::Target { 525 - &self.config 526 - } 527 - }
crates/knot/src/model/nicediff.rs crates/gordian-knot/src/model/nicediff.rs
-913
crates/knot/src/model/repository.rs
··· 1 - mod merge_check; 2 - 3 - use core::{error, fmt}; 4 - use std::{ 5 - collections::{BTreeMap, HashSet, VecDeque}, 6 - io, ops, 7 - path::{Path, PathBuf}, 8 - process::{Command, Stdio}, 9 - }; 10 - 11 - use axum::{ 12 - Json, 13 - extract::{FromRef, FromRequestParts}, 14 - }; 15 - use git_service::util::{SetOptionArg, SetOptionEnv as _}; 16 - use gix::{ 17 - ObjectId, 18 - bstr::{BString, ByteSlice}, 19 - submodule::config::Branch, 20 - }; 21 - use lexicon::sh_tangled::repo::{ 22 - blob::Submodule, branch, get_default_branch, languages, refs, tree, 23 - }; 24 - use rustc_hash::FxHashSet; 25 - use serde::Deserialize; 26 - 27 - use crate::{ 28 - model::{convert, errors, nicediff}, 29 - public::xrpc::{XrpcError, XrpcQuery, XrpcResponse, XrpcResult}, 30 - types::{ 31 - repository_key::RepositoryKey, 32 - repository_path::RepositoryPath, 33 - sh_tangled::repo::{branches, compare, diff, log, tags}, 34 - }, 35 - }; 36 - 37 - use super::Knot; 38 - 39 - #[derive(Debug)] 40 - pub struct TangledRepository { 41 - knot: Knot, 42 - repo_key: RepositoryKey, 43 - repository: gix::Repository, 44 - } 45 - 46 - impl From<(Knot, RepositoryKey, gix::Repository)> for TangledRepository { 47 - fn from((knot, repo_key, repository): (Knot, RepositoryKey, gix::Repository)) -> Self { 48 - Self { 49 - knot, 50 - repo_key, 51 - repository, 52 - } 53 - } 54 - } 55 - 56 - #[derive(Debug, thiserror::Error)] 57 - #[error(transparent)] 58 - pub struct TreeError(#[from] gix::object::commit::Error); 59 - 60 - #[derive(Debug, thiserror::Error)] 61 - pub enum BlobError { 62 - #[error(transparent)] 63 - PathLookupError(#[from] gix::object::find::existing::Error), 64 - #[error("File not found at the specified path")] 65 - FileNotFound, 66 - #[error("Requested path is not a blob")] 67 - NotABlob, 68 - } 69 - 70 - impl TangledRepository { 71 - pub fn path(&self) -> &Path { 72 - self.repository.path() 73 - } 74 - 75 - pub fn repository_key(&self) -> &RepositoryKey { 76 - &self.repo_key 77 - } 78 - 79 - /// Resolve a revspec into a [`(gix::Commit, bool)`] tuple. 80 - /// 81 - /// The boolean value indicates whether the revspec is immutable (ie. if 82 - /// it is an object ID). 83 - fn resolve_revspec(&self, revspec: Option<&str>) -> Result<(gix::Commit<'_>, bool), XrpcError> { 84 - use std::str::FromStr as _; 85 - 86 - Ok(if let Some(refspec) = revspec { 87 - match gix::ObjectId::from_str(refspec) { 88 - Ok(id) => ( 89 - self.repository 90 - .find_commit(id) 91 - .map_err(errors::RefNotFound)?, 92 - true, 93 - ), 94 - Err(_) => { 95 - // Assume the revspec is a branch or tag. 96 - let mut reference = self 97 - .repository 98 - .find_reference(refspec) 99 - .map_err(errors::RefNotFound)?; 100 - ( 101 - reference.peel_to_commit().map_err(errors::RefNotFound)?, 102 - false, 103 - ) 104 - } 105 - } 106 - } else { 107 - ( 108 - self.repository.head_commit().map_err(errors::RefNotFound)?, 109 - false, 110 - ) 111 - }) 112 - } 113 - 114 - pub fn get_tree<'repo>( 115 - &self, 116 - commit: &gix::Commit<'repo>, 117 - ) -> Result<gix::Tree<'repo>, TreeError> { 118 - Ok(commit.tree()?) 119 - } 120 - 121 - pub fn get_blob(&self, tree: &gix::Tree<'_>, path: &Path) -> Result<Vec<u8>, BlobError> { 122 - let entry = tree 123 - .lookup_entry_by_path(path)? 124 - .ok_or(BlobError::FileNotFound)?; 125 - 126 - if !(entry.mode().is_blob() || entry.mode().is_link()) { 127 - return Err(BlobError::NotABlob); 128 - } 129 - 130 - Ok(entry.object()?.into_blob().take_data()) 131 - } 132 - 133 - pub fn branch(&self, params: branch::Input) -> XrpcResult<Json<branch::Output>> { 134 - let mut reference = self 135 - .repository 136 - .find_reference(&params.name) 137 - .map_err(errors::RefNotFound)?; 138 - 139 - let commit = reference.peel_to_commit().map_err(errors::RefNotFound)?; 140 - let name = reference.name().shorten().to_string(); 141 - let hash = commit.id.into(); 142 - let time = commit 143 - .committer() 144 - .map_err(errors::RepoError)? 145 - .time() 146 - .map_err(errors::RepoError)?; 147 - 148 - let when = convert::time_to_offsetdatetime(&time).map_err(errors::RepoError)?; 149 - let author = convert::try_convert_signature(commit.author().map_err(errors::RepoError)?)?; 150 - let message = commit 151 - .message() 152 - .map_err(errors::RepoError)? 153 - .summary() 154 - .to_string(); 155 - 156 - // Assume HEAD points to the intended default branch. This *should* be 157 - // true for a bare repository. 158 - let head = self.repository.head()?; 159 - let default_name = head 160 - .referent_name() 161 - .ok_or(errors::HeadDetached)? 162 - .shorten() 163 - .to_string(); 164 - 165 - let is_default = default_name == name; 166 - 167 - Ok(Json(branch::Output { 168 - name, 169 - hash, 170 - when, 171 - author, 172 - message, 173 - is_default, 174 - }) 175 - .into()) 176 - } 177 - 178 - pub fn branches(&self, _params: branches::Input) -> XrpcResult<Json<branches::Output>> { 179 - // Assume HEAD points to the intended default branch. This *should* be 180 - // true for a bare repository. 181 - let head = self.repository.head()?; 182 - let default_name = head 183 - .referent_name() 184 - .ok_or(errors::HeadDetached)? 185 - .shorten() 186 - .to_string(); 187 - 188 - let mut branches = Vec::new(); 189 - for branch in self.repository.references()?.local_branches()? 190 - // .skip(params.cursor) 191 - // .take(_params.limit.into()) 192 - { 193 - let Ok(branch) = branch.inspect_err(|error| tracing::error!(?error)) else { 194 - continue; 195 - }; 196 - 197 - let name = branch.name().shorten().to_string(); 198 - let Some(id) = branch.try_id() else { 199 - tracing::warn!(?name, "branch unborn, skipping"); 200 - continue; 201 - }; 202 - 203 - let Ok(commit) = self.repository.find_commit(id) else { 204 - tracing::error!(?name, ?id, "failed to find commit for branch"); 205 - continue; 206 - }; 207 - 208 - let is_default = name == default_name; 209 - branches.push(branches::Branch { 210 - reference: refs::Reference { 211 - name, 212 - hash: commit.id.into(), 213 - }, 214 - commit: convert::try_convert_commit(commit)?, 215 - is_default, 216 - }); 217 - } 218 - 219 - Ok(Json(branches::Output { branches }).into()) 220 - } 221 - 222 - pub fn compare(&self, params: compare::Input) -> XrpcResult<Json<compare::Output>> { 223 - let (rev1, rev1_immutable) = self.resolve_revspec(Some(&params.rev1))?; 224 - let (rev2, rev2_immutable) = self.resolve_revspec(Some(&params.rev2))?; 225 - 226 - let mut seen = HashSet::new(); 227 - let mut commits = self 228 - .repository 229 - .rev_walk([rev2.id]) 230 - .with_hidden([rev1.id]) 231 - .selected(|oid| seen.insert(oid.to_owned())) 232 - .map_err(errors::RepoError)? 233 - .take_while(|val| { 234 - val.as_ref() 235 - .is_ok_and(|commit| commit.parent_ids.len() == 1) 236 - }) 237 - .collect::<Result<Vec<_>, _>>() 238 - .map_err(errors::RepoError)?; 239 - 240 - commits.reverse(); 241 - 242 - let mut format_patch_raw = String::new(); 243 - for commit in commits { 244 - let output = self 245 - .git() 246 - .arg("format-patch") 247 - .arg("-1") 248 - .arg(commit.id.to_hex().to_string()) 249 - .arg("--stdout") 250 - .output() 251 - .map_err(errors::Internal)?; 252 - 253 - format_patch_raw.push_str(&output.stdout.to_str_lossy()); 254 - format_patch_raw.push('\n'); 255 - } 256 - 257 - Ok(XrpcResponse { 258 - response: Json(compare::Output { 259 - rev1: rev1.id.into(), 260 - rev2: rev2.id.into(), 261 - format_patch_raw, 262 - }), 263 - immutable: rev1_immutable && rev2_immutable, 264 - }) 265 - } 266 - 267 - pub fn diff(&self, params: diff::Input) -> XrpcResult<Json<diff::Output>> { 268 - let (this_commit, immutable) = self.resolve_revspec(Some(&params.rev))?; 269 - let diff = nicediff::unified_diff_from_parent(this_commit).unwrap(); 270 - let response = diff::Output { 271 - rev: params.rev.into(), 272 - diff, 273 - }; 274 - 275 - Ok(XrpcResponse { 276 - response: Json(response), 277 - immutable, 278 - }) 279 - } 280 - 281 - pub fn get_default_branch( 282 - &self, 283 - _: get_default_branch::Input, 284 - ) -> XrpcResult<Json<get_default_branch::Output>> { 285 - // Assume HEAD points the intended default branch. This *should* be true 286 - // for a bare repository. 287 - let mut head = self.repository.head()?; 288 - let name = head 289 - .referent_name() 290 - .ok_or(errors::HeadDetached)? 291 - .shorten() 292 - .to_string(); 293 - 294 - let hash = head.id().map(|id| id.detach().into()); 295 - let when = head 296 - .peel_to_commit() 297 - .ok() 298 - .and_then(|commit| { 299 - commit 300 - .committer() 301 - .ok() 302 - .and_then(|committer| committer.time().ok()) 303 - }) 304 - .and_then(|time| convert::time_to_offsetdatetime(&time).ok()); 305 - 306 - Ok(Json(get_default_branch::Output { name, hash, when }).into()) 307 - } 308 - 309 - pub fn languages(&self, _: languages::Input) -> XrpcResult<Json<languages::Output>> { 310 - Ok(Json(languages::Output::default()).into()) 311 - } 312 - 313 - pub fn log(&self, params: log::Input) -> XrpcResult<Json<log::Output>> { 314 - let commit_graph = self.repository.commit_graph_if_enabled()?; 315 - let total = match &commit_graph { 316 - Some(cg) => cg 317 - .num_commits() 318 - .try_into() 319 - .expect("You must be at least 32 bits tall to enjoy this ride"), 320 - None => { 321 - tracing::warn!(repository = ?self.repository, "no commit-graph, counting commits manually"); 322 - self.repository 323 - .rev_walk([self.repository.head_id().map_err(errors::RepoEmpty)?]) 324 - .all() 325 - .map_err(errors::RepoError)? 326 - .count() 327 - } 328 - }; 329 - 330 - let (tip, _) = self.resolve_revspec(params.rev.as_deref())?; 331 - 332 - let mut commits = Vec::new(); 333 - for commit in self 334 - .repository 335 - .rev_walk([tip.id()]) 336 - .with_commit_graph(commit_graph) 337 - .all() 338 - .map_err(errors::RepoError)? 339 - .skip(params.cursor) 340 - .take(params.limit.into()) 341 - { 342 - match commit { 343 - Ok(commit) => { 344 - let commit = self 345 - .repository 346 - .find_commit(commit.id()) 347 - .map_err(errors::RepoError)?; 348 - commits.push(convert::try_convert_commit(commit).map_err(errors::RepoError)?); 349 - } 350 - Err(error) => { 351 - tracing::error!(?error); 352 - break; 353 - } 354 - } 355 - } 356 - 357 - Ok(Json(log::Output { 358 - commits, 359 - log: true, 360 - total, 361 - page: 1 + params.cursor / usize::from(params.limit), 362 - per_page: params.limit, 363 - }) 364 - .into()) 365 - } 366 - 367 - pub fn tags(&self, _: tags::Input) -> XrpcResult<Json<tags::Output>> { 368 - use std::cmp::Reverse; 369 - 370 - let mut tags: Vec<_> = self 371 - .repository 372 - .references()? 373 - .tags()? 374 - .filter_map(|tag| { 375 - tag.inspect_err(|error| tracing::error!(?error)) 376 - .ok()? 377 - .try_into() 378 - .inspect_err(|error| tracing::error!(?error)) 379 - .ok() 380 - }) 381 - .collect(); 382 - 383 - tags.sort_by_key(|tag: &tags::Tag| { 384 - Reverse( 385 - tag.annotation 386 - .as_ref() 387 - .map(|an| an.tagger.as_ref().map(|tagger| tagger.when)), 388 - ) 389 - }); 390 - 391 - Ok(Json(tags::Output { tags }).into()) 392 - } 393 - 394 - pub fn tree( 395 - &self, 396 - params: tree::Input, 397 - readmes: &FxHashSet<BString>, 398 - ) -> XrpcResult<Json<tree::Output>> { 399 - let (tip, immutable) = self.resolve_revspec(params.rev.as_deref())?; 400 - let dotdot = params.path.clone().and_then(|mut path| { 401 - path.pop(); 402 - match path.as_os_str().is_empty() { 403 - true => None, 404 - false => Some(path), 405 - } 406 - }); 407 - 408 - let mut parent = None; 409 - let mut tree = tip.tree()?; 410 - if let Some(subpath) = &params.path { 411 - let entry = tree 412 - .lookup_entry_by_path(subpath)? 413 - .ok_or(errors::PathNotFound(subpath.to_string_lossy()))?; 414 - 415 - if !entry.mode().is_tree() { 416 - return Ok(XrpcResponse { 417 - response: Json(tree::Output { 418 - files: vec![], 419 - dotdot: dotdot.map(|path| path.into()), 420 - parent: params.path.map(|path| path.into()), 421 - rev: params.rev.as_deref().unwrap_or_default().to_string(), 422 - readme: None, 423 - }), 424 - immutable, 425 - }); 426 - } 427 - 428 - let subtree = self.repository.find_tree(entry.id()).unwrap(); 429 - tree = subtree; 430 - parent = Some(subpath.to_path_buf()); 431 - } 432 - 433 - let mut files: Vec<tree::TreeEntry> = vec![]; 434 - let mut readme = None; 435 - for entry in tree.iter() { 436 - let Ok(entry) = entry else { 437 - continue; 438 - }; 439 - 440 - if readmes.contains(entry.filename()) && entry.mode().is_blob() && readme.is_none() { 441 - let mut file = self.repository.find_blob(entry.id())?; 442 - if let Ok(contents) = String::from_utf8(file.take_data()) { 443 - readme.replace(tree::Readme { 444 - contents, 445 - filename: entry.filename().to_string(), 446 - }); 447 - } 448 - } 449 - 450 - files.push(convert::convert_entry(entry)); 451 - } 452 - 453 - let files: Vec<_> = tree 454 - .iter() 455 - .filter_map(|entry| { 456 - let entry = entry.ok()?; 457 - let file = convert::convert_entry(entry); 458 - Some(file) 459 - }) 460 - .collect(); 461 - 462 - Ok(XrpcResponse { 463 - response: Json(tree::Output { 464 - files, 465 - dotdot: dotdot.map(|path| path.into()), 466 - parent, 467 - rev: params.rev.as_deref().unwrap_or_default().to_string(), 468 - readme, 469 - }), 470 - immutable, 471 - }) 472 - } 473 - 474 - pub fn submodule(&self, path: &Path) -> Option<Submodule> { 475 - if let Ok(Some(submodules)) = self.repository.modules() 476 - && let Some(name) = submodules.name_by_path(path.as_os_str().as_encoded_bytes().into()) 477 - { 478 - let url = submodules.url(name).ok()?.to_string(); 479 - let branch = submodules 480 - .branch(name) 481 - .ok()? 482 - .and_then(|branch| match branch { 483 - Branch::CurrentInSuperproject => None, 484 - Branch::Name(name) => Some(name.to_string()), 485 - }); 486 - 487 - let name = name.to_string(); 488 - return Some(Submodule { name, url, branch }); 489 - } 490 - 491 - None 492 - } 493 - } 494 - 495 - impl<S: Send + Sync> FromRequestParts<S> for TangledRepository 496 - where 497 - Knot: axum::extract::FromRef<S>, 498 - { 499 - type Rejection = XrpcError; 500 - 501 - async fn from_request_parts( 502 - parts: &mut axum::http::request::Parts, 503 - state: &S, 504 - ) -> Result<Self, Self::Rejection> { 505 - #[derive(Deserialize)] 506 - struct Param { 507 - repo: String, 508 - } 509 - 510 - let XrpcQuery(Param { repo }) = XrpcQuery::from_request_parts(parts, state).await?; 511 - let repo_path: RepositoryPath = repo.parse().map_err(errors::InvalidRequest)?; 512 - 513 - let knot = Knot::from_ref(state); 514 - let repo_key = knot 515 - .resolve_repo_key(&repo_path) 516 - .await 517 - .map_err(errors::RepoNotFound)?; 518 - 519 - let repository = knot 520 - .open_repository(&repo_key) 521 - .await 522 - .map_err(errors::RepoNotFound)? 523 - .to_thread_local(); 524 - 525 - Ok(Self { 526 - knot, 527 - repo_key, 528 - repository, 529 - }) 530 - } 531 - } 532 - 533 - impl TangledRepository { 534 - pub async fn from_git_request<S: Send + Sync>( 535 - parts: &mut axum::http::request::Parts, 536 - state: &S, 537 - ) -> Result<Self, crate::public::git::Error> 538 - where 539 - Knot: axum::extract::FromRef<S>, 540 - { 541 - use crate::public::git::NotFound; 542 - use axum::extract::Path; 543 - 544 - let knot = Knot::from_ref(state); 545 - let Path(repo_path) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?; 546 - let repo_key = knot.resolve_repo_key(&repo_path).await.map_err(NotFound)?; 547 - 548 - let repository = knot 549 - .open_repository(&repo_key) 550 - .await 551 - .map_err(NotFound)? 552 - .to_thread_local(); 553 - 554 - Ok(Self { 555 - knot, 556 - repo_key, 557 - repository, 558 - }) 559 - } 560 - } 561 - 562 - impl TangledRepository { 563 - /// Initialise a [`Command`] for running git with the appropriate environment and working 564 - /// directory for the repository. 565 - /// 566 - pub fn git(&self) -> Command { 567 - use crate::private::{ENV_PRIVATE_ENDPOINTS, ENV_REPO_DID, ENV_REPO_RKEY}; 568 - 569 - let mut command = Command::new("/usr/bin/git"); 570 - command 571 - .current_dir(self.path()) 572 - .env_clear() 573 - .env("GIT_CONFIG_GLOBAL", self.knot.git_config_path()) 574 - .env(ENV_PRIVATE_ENDPOINTS, self.knot.private_endpoints()) 575 - .env(ENV_REPO_DID, self.repo_key.owner_str()) 576 - .env(ENV_REPO_RKEY, &self.repo_key.rkey); 577 - 578 - command 579 - } 580 - } 581 - 582 - /// A temporary detached worktree generated with a randomised name, and deleted when 583 - /// the object is dropped. 584 - /// 585 - #[derive(Debug)] 586 - struct TempWorktree<'repo> { 587 - repo: &'repo gix::Repository, 588 - 589 - /// Worktree name. 590 - // 591 - // Used to remove the worktree later. 592 - name: String, 593 - 594 - /// Path to the worktree. 595 - path: PathBuf, 596 - 597 - /// Path to the global git configuration file. 598 - config: Option<PathBuf>, 599 - } 600 - 601 - impl<'repo> TempWorktree<'repo> { 602 - pub fn builder() -> TempWorktreeBuilder<'repo> { 603 - TempWorktreeBuilder::new() 604 - } 605 - 606 - /// Get the absolute path to the worktree. 607 - pub fn path(&self) -> &Path { 608 - &self.path 609 - } 610 - } 611 - 612 - impl<'repo> Drop for TempWorktree<'repo> { 613 - fn drop(&mut self) { 614 - tracing::debug!(?self, "removing temporary worktree"); 615 - if let Err(error) = Command::new("/usr/bin/git") 616 - .env_clear() 617 - .current_dir(self.repo.path()) 618 - .option_env("GIT_GLOBAL_CONFIG", self.config.as_deref()) 619 - .arg("-C") 620 - .arg(self.repo.path()) 621 - .arg("worktree") 622 - .arg("remove") 623 - .arg(&self.name) 624 - .stderr(Stdio::inherit()) 625 - .stdout(Stdio::null()) 626 - .output() 627 - { 628 - tracing::error!(?self, ?error, "failed to remove temporary worktree"); 629 - } 630 - } 631 - } 632 - 633 - #[derive(Clone, Debug)] 634 - pub struct TempWorktreeBuilder<'a> { 635 - prefix: Option<&'a str>, 636 - 637 - // Commit object ID to create the worktree from. 638 - commit: Option<&'a ObjectId>, 639 - 640 - /// Path to the global git config. 641 - config: Option<&'a Path>, 642 - } 643 - 644 - impl<'a> TempWorktreeBuilder<'a> { 645 - pub const fn new() -> Self { 646 - Self { 647 - prefix: None, 648 - commit: None, 649 - config: None, 650 - } 651 - } 652 - 653 - /// Set a prefix for the randomly generated worktree name. 654 - pub const fn prefix(&mut self, prefix: &'a str) -> &mut Self { 655 - self.prefix = Some(prefix); 656 - self 657 - } 658 - 659 - /// Set a commit id to create the worktree from. 660 - pub const fn commit(&mut self, commit: &'a ObjectId) -> &mut Self { 661 - self.commit = Some(commit); 662 - self 663 - } 664 - 665 - /// Set the path to the global git config file. 666 - pub const fn config(&mut self, config: &'a Path) -> &mut Self { 667 - self.config = Some(config); 668 - self 669 - } 670 - 671 - /// Build the temporary worktree. 672 - /// 673 - /// # Errors 674 - /// 675 - /// Returns an error if the git subprocess could not be spawned or exits with a non-zero 676 - /// exit code. 677 - /// 678 - /// # Panics 679 - /// 680 - /// Panics if `repo` is not a bare repository. 681 - /// 682 - fn build<'repo>(&self, repo: &'repo gix::Repository) -> io::Result<TempWorktree<'repo>> { 683 - assert!(repo.is_bare(), "repository should be bare"); 684 - 685 - let mut name = 686 - String::with_capacity(self.prefix.map(|val| val.len() + 1).unwrap_or_default() + 13); 687 - 688 - let random_bytes: [u8; 8] = rand::random(); 689 - if let Some(prefix) = self.prefix { 690 - name.push_str(prefix); 691 - name.push('-'); 692 - } 693 - 694 - data_encoding::BASE32_NOPAD_VISUAL.encode_append(&random_bytes, &mut name); 695 - name = name.to_lowercase(); 696 - 697 - let commit = self.commit.map(ToString::to_string); 698 - let config = self.config; 699 - let output = Command::new("/usr/bin/git") 700 - .env_clear() 701 - .current_dir(repo.path()) 702 - .option_env("GIT_GLOBAL_CONFIG", config) 703 - .arg("-C") 704 - .arg(repo.path()) 705 - .arg("worktree") 706 - .arg("add") 707 - .arg("--detach") 708 - .arg(&name) 709 - .option_arg(commit) 710 - .stderr(Stdio::piped()) 711 - .stdout(Stdio::null()) 712 - .output()?; 713 - 714 - if !output.status.success() { 715 - let message = String::from_utf8_lossy(&output.stderr); 716 - return Err(io::Error::other(format!( 717 - "Failed to create temporary worktree: {message}" 718 - ))); 719 - } 720 - 721 - let path = repo.path().join(&name); 722 - Ok(TempWorktree { 723 - repo, 724 - name, 725 - path, 726 - config: config.map(Path::to_path_buf), 727 - }) 728 - } 729 - } 730 - 731 - #[derive(Debug)] 732 - pub struct RevspecError(pub Box<dyn error::Error + Send + Sync + 'static>); 733 - 734 - impl fmt::Display for RevspecError { 735 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 736 - fmt::Display::fmt(&self.0, f) 737 - } 738 - } 739 - 740 - impl error::Error for RevspecError {} 741 - 742 - impl From<gix::object::find::existing::with_conversion::Error> for RevspecError { 743 - fn from(value: gix::object::find::existing::with_conversion::Error) -> Self { 744 - Self(value.into()) 745 - } 746 - } 747 - 748 - impl From<gix::reference::find::existing::Error> for RevspecError { 749 - fn from(value: gix::reference::find::existing::Error) -> Self { 750 - Self(value.into()) 751 - } 752 - } 753 - 754 - impl From<gix::reference::peel::to_kind::Error> for RevspecError { 755 - fn from(value: gix::reference::peel::to_kind::Error) -> Self { 756 - Self(value.into()) 757 - } 758 - } 759 - 760 - impl From<gix::reference::head_commit::Error> for RevspecError { 761 - fn from(value: gix::reference::head_commit::Error) -> Self { 762 - Self(value.into()) 763 - } 764 - } 765 - 766 - pub struct ResolvedRevspec<'a> { 767 - /// Resolved commit. 768 - pub commit: gix::Commit<'a>, 769 - 770 - /// `true` if the revspec is immutable, for example an object ID. 771 - pub immutable: bool, 772 - } 773 - 774 - pub trait ResolveRevspec { 775 - /// Resolve a revspec into a [`ResolvedRevspec`] tuple. 776 - fn resolve_revspec<'repo, R>( 777 - &'repo self, 778 - revspec: &Option<R>, 779 - ) -> Result<ResolvedRevspec<'repo>, RevspecError> 780 - where 781 - R: ops::Deref<Target = str>; 782 - } 783 - 784 - impl ResolveRevspec for gix::Repository { 785 - fn resolve_revspec<'repo, R>( 786 - &'repo self, 787 - revspec: &Option<R>, 788 - ) -> Result<ResolvedRevspec<'repo>, RevspecError> 789 - where 790 - R: ops::Deref<Target = str>, 791 - { 792 - use std::str::FromStr as _; 793 - 794 - Ok(if let Some(refspec) = revspec.as_deref() { 795 - match gix::ObjectId::from_str(refspec) { 796 - Ok(id) => ResolvedRevspec { 797 - commit: self.find_commit(id)?, 798 - immutable: true, 799 - }, 800 - Err(_) => { 801 - // Assume the revspec is a branch or tag. 802 - let mut reference = self.find_reference(refspec)?; 803 - ResolvedRevspec { 804 - commit: reference.peel_to_commit()?, 805 - immutable: false, 806 - } 807 - } 808 - } 809 - } else { 810 - ResolvedRevspec { 811 - commit: self.head_commit()?, 812 - immutable: false, 813 - } 814 - }) 815 - } 816 - } 817 - 818 - impl ResolveRevspec for TangledRepository { 819 - fn resolve_revspec<'repo, R>( 820 - &'repo self, 821 - revspec: &Option<R>, 822 - ) -> Result<ResolvedRevspec<'repo>, RevspecError> 823 - where 824 - R: ops::Deref<Target = str>, 825 - { 826 - self.repository.resolve_revspec(revspec) 827 - } 828 - } 829 - 830 - pub trait RepositoryStatsExt { 831 - fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64>; 832 - 833 - fn language_breakdown(&self, at: &ObjectId) -> BTreeMap<String, u64>; 834 - } 835 - 836 - impl RepositoryStatsExt for gix::Repository { 837 - fn count_commits(&self, start: &ObjectId, end: &ObjectId) -> BTreeMap<String, u64> { 838 - let mut counts = BTreeMap::new(); 839 - 840 - let commit_graph = self.commit_graph_if_enabled().ok().flatten(); 841 - let walk = self 842 - .rev_walk([start.clone()]) 843 - .with_commit_graph(commit_graph) 844 - .with_boundary([end.clone()]) 845 - .all(); 846 - 847 - let Ok(walk) = walk else { 848 - return Default::default(); 849 - }; 850 - 851 - for commit in walk { 852 - let Ok(commit) = commit else { 853 - break; 854 - }; 855 - let Ok(commit) = commit.object() else { 856 - break; 857 - }; 858 - let Ok(author) = commit.author() else { 859 - break; 860 - }; 861 - 862 - *counts.entry(author.email.to_string()).or_default() += 1; 863 - } 864 - 865 - counts 866 - } 867 - 868 - fn language_breakdown(&self, at: &ObjectId) -> BTreeMap<String, u64> { 869 - let mut languages = BTreeMap::new(); 870 - 871 - let worktree = TempWorktree::builder() 872 - .prefix("languages-scan") 873 - .commit(at) 874 - .build(&self); 875 - 876 - let worktree = match worktree { 877 - Err(error) => { 878 - tracing::error!(?error, "error creating temporary worktree"); 879 - return languages; 880 - } 881 - Ok(worktree) => worktree, 882 - }; 883 - 884 - let mut to_scan = VecDeque::new(); 885 - to_scan.push_back(worktree.path().to_path_buf()); 886 - 887 - while let Some(dir) = to_scan.pop_front() { 888 - for entry in std::fs::read_dir(dir).unwrap() { 889 - let Ok(entry) = entry else { 890 - continue; 891 - }; 892 - 893 - let Ok(metadata) = entry.metadata() else { 894 - continue; 895 - }; 896 - 897 - if metadata.is_dir() { 898 - to_scan.push_back(entry.path()); 899 - continue; 900 - } 901 - 902 - let language = match entry.path().extension().map(|val| val.as_encoded_bytes()) { 903 - Some(b"rs") => "Rust", 904 - _ => "Other", 905 - }; 906 - 907 - *languages.entry(language.to_string()).or_default() += metadata.len(); 908 - } 909 - } 910 - 911 - languages 912 - } 913 - }
-102
crates/knot/src/model/repository/merge_check.rs
··· 1 - use std::{borrow::Cow, io::Write as _, process::Stdio}; 2 - 3 - use axum::Json; 4 - use lexicon::sh_tangled::repo::merge_check::{ConflictInfo, Output}; 5 - 6 - use crate::{ 7 - model::{ 8 - errors, 9 - repository::{ResolveRevspec as _, ResolvedRevspec, TempWorktree}, 10 - }, 11 - public::xrpc::{XrpcError, XrpcResponse}, 12 - }; 13 - 14 - impl super::TangledRepository { 15 - pub fn merge_check( 16 - &self, 17 - patch: String, 18 - branch: &str, 19 - ) -> Result<XrpcResponse<Json<Output>>, XrpcError> { 20 - let ResolvedRevspec { commit, immutable } = 21 - self.repository.resolve_revspec(&Some(branch.as_ref()))?; 22 - 23 - let worktree = TempWorktree::builder() 24 - .prefix("merge-check") 25 - .config(&self.knot.git_config_path()) 26 - .commit(&commit.id) 27 - .build(&self.repository) 28 - .map_err(errors::Internal)?; 29 - 30 - let mut child = self 31 - .git() 32 - .arg("-C") 33 - .arg(worktree.path()) 34 - .arg("apply") 35 - .arg("--check") 36 - .arg("--verbose") 37 - .arg("-") 38 - .stdin(Stdio::piped()) 39 - .stderr(Stdio::piped()) 40 - .spawn() 41 - .map_err(errors::Internal)?; 42 - 43 - let mut stdin = child.stdin.take().expect("handle present"); 44 - let writer = std::thread::spawn(move || stdin.write_all(patch.as_bytes())); 45 - let output = child.wait_with_output().map_err(errors::Internal)?; 46 - 47 - writer 48 - .join() 49 - .expect("thread should not panic") 50 - .map_err(errors::Internal)?; 51 - 52 - let errors = std::str::from_utf8(&output.stderr).map_err(errors::Internal)?; 53 - let conflicts = parse_git_apply_check_errors(errors); 54 - let is_conflicted = !output.status.success() && !conflicts.is_empty(); 55 - let message = is_conflicted.then_some(Cow::Borrowed("patch cannot be applied cleanly")); 56 - 57 - Ok(XrpcResponse { 58 - response: Json(Output { 59 - is_conflicted, 60 - conflicts, 61 - message, 62 - error: None, 63 - }), 64 - immutable, 65 - }) 66 - } 67 - } 68 - 69 - fn parse_git_apply_check_errors(stderr: &str) -> Vec<ConflictInfo> { 70 - let mut hunk_name = None; 71 - stderr 72 - .lines() 73 - .filter_map(|line| { 74 - let mut parts = line.splitn(3, ':').map(|s| s.trim()); 75 - match (parts.next(), parts.next(), parts.next()) { 76 - (Some("error"), Some("patch failed"), Some(hunk)) => { 77 - hunk_name = Some(hunk); 78 - None 79 - } 80 - (Some("error"), Some(filename), Some("already exists in working directory")) => { 81 - Some(ConflictInfo { 82 - filename: hunk_name.unwrap_or(filename).to_owned(), 83 - reason: Cow::Borrowed("file already exists"), 84 - }) 85 - } 86 - (Some("error"), Some(filename), Some("does not exist in working tree")) => { 87 - Some(ConflictInfo { 88 - filename: hunk_name.unwrap_or(filename).to_owned(), 89 - reason: Cow::Borrowed("file does not exist"), 90 - }) 91 - } 92 - (Some("error"), Some(filename), Some("patch does not apply")) => { 93 - Some(ConflictInfo { 94 - filename: hunk_name.unwrap_or(filename).to_owned(), 95 - reason: Cow::Borrowed("patch does not apply"), 96 - }) 97 - } 98 - _ => None, 99 - } 100 - }) 101 - .collect() 102 - }
-330
crates/knot/src/private.rs
··· 1 - use core::fmt; 2 - use std::{borrow::Cow, io, process::Stdio, sync::Arc}; 3 - 4 - use atproto::did::OwnedDid; 5 - use axum::{ 6 - extract::{FromRequestParts, Path, State}, 7 - http::{HeaderMap, StatusCode, request::Parts}, 8 - response::IntoResponse, 9 - }; 10 - use lexicon::sh_tangled::git::{ 11 - CommitCount, CommitCountBreakdown, Language, LanguageBreakdown, Meta, RefUpdate, 12 - }; 13 - use serde::{Deserialize, Serialize}; 14 - use time::OffsetDateTime; 15 - use tokio_rayon::AsyncThreadPool as _; 16 - 17 - use crate::{ 18 - model::{ 19 - Knot, errors, 20 - knot_state::Event, 21 - repository::{RepositoryStatsExt as _, TangledRepository}, 22 - }, 23 - public::xrpc::XrpcError, 24 - types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 25 - }; 26 - 27 - /// Environment variable containing one or more whitespace separated URLs for the internal API. 28 - /// 29 - /// By default, knot will serve the internal API on all the addresses resolved from `localhost` 30 - /// bound to a OS assigned port. 31 - /// 32 - /// # Example 33 - /// 34 - /// `"http://[::1]:44269/ http://127.0.0.1:36413/"` 35 - /// 36 - pub const ENV_PRIVATE_ENDPOINTS: &str = "GORDIAN_PRIVATE_ENDPOINTS"; 37 - 38 - /// Environment variable containing the DID of the account that triggered the hook. 39 - pub const ENV_USER_DID: &str = "GORDIAN_USER_DID"; 40 - 41 - /// Environment variable containing the DID that owns the repository the hook has be triggered on. 42 - pub const ENV_REPO_DID: &str = "GORDIAN_REPO_DID"; 43 - 44 - /// Environment variable containing the rkey of the repository the hook has be triggered on. 45 - pub const ENV_REPO_RKEY: &str = "GORDIAN_REPO_RKEY"; 46 - 47 - /// Prefix to add when converting an environment variable from the hook to a HTTP header. 48 - pub const ENV_HEADER_PREFIX: &str = "X-Gordian"; 49 - 50 - /// Build a new router for the internal API. 51 - #[rustfmt::skip] 52 - pub fn router() -> axum::Router<Knot> { 53 - use axum::routing::post; 54 - axum::Router::new() 55 - .without_v07_checks() 56 - .route("/hook/{owner}/{rkey}/pre-receive", post(hook_pre_receive)) 57 - .route("/hook/{owner}/{rkey}/post-receive", post(hook_post_receive)) 58 - .route("/hook/{owner}/{rkey}/post-update", post(hook_post_update)) 59 - } 60 - 61 - /// Hooks handled by knot. 62 - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 63 - #[serde(rename_all = "kebab-case")] 64 - pub enum Hook { 65 - PostReceive, 66 - PostUpdate, 67 - PreReceive, 68 - } 69 - 70 - impl Hook { 71 - /// Get the hook name as a `&'static str`. 72 - pub fn as_str(&self) -> &'static str { 73 - match self { 74 - Self::PostReceive => "post-receive", 75 - Self::PostUpdate => "post-update", 76 - Self::PreReceive => "pre-receive", 77 - } 78 - } 79 - } 80 - 81 - impl fmt::Display for Hook { 82 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 83 - f.write_str(self.as_str()) 84 - } 85 - } 86 - 87 - /// Extracts the 'X-Gordian-User-Did' header from a request. 88 - pub struct ActorDid(pub OwnedDid); 89 - 90 - impl<S: Sync> FromRequestParts<S> for ActorDid { 91 - type Rejection = (StatusCode, Cow<'static, str>); 92 - 93 - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { 94 - macro_rules! hdr { 95 - () => { 96 - "X-Gordian-User-Did" 97 - }; 98 - } 99 - 100 - const HEADER: &str = hdr!(); 101 - 102 - let did = parts 103 - .headers 104 - .get(HEADER) 105 - .ok_or(( 106 - StatusCode::BAD_REQUEST, 107 - concat!("'", hdr!(), "' header required").into(), 108 - ))? 109 - .to_str() 110 - .map_err(|_| { 111 - ( 112 - StatusCode::BAD_REQUEST, 113 - concat!("'", hdr!(), "' contains invalid ASCII").into(), 114 - ) 115 - })? 116 - .parse() 117 - .map_err(|_| { 118 - ( 119 - StatusCode::BAD_REQUEST, 120 - concat!("'", hdr!(), "' contains invalid DID").into(), 121 - ) 122 - })?; 123 - 124 - Ok(Self(did)) 125 - } 126 - } 127 - 128 - #[tracing::instrument(skip(knot, headers))] 129 - async fn hook_pre_receive( 130 - State(knot): State<Knot>, 131 - Path(RepositoryKey { owner, rkey }): Path<RepositoryKey>, 132 - ActorDid(actor): ActorDid, 133 - headers: HeaderMap, 134 - ) -> Result<impl IntoResponse, impl IntoResponse> { 135 - use data_encoding::BASE64_NOPAD as Encoding; 136 - 137 - let push_certificate = PushCertificate::try_from(&headers) 138 - .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 139 - 140 - // @TODO Make acceptable slop configurable. 141 - if !push_certificate.is_good(Some(5)) { 142 - tracing::error!("push certificate rejected"); 143 - return Err(( 144 - StatusCode::FORBIDDEN, 145 - "Push certificate rejected".to_owned(), 146 - )); 147 - } 148 - 149 - let certificate_key_digest = push_certificate 150 - .signing_key_digest() 151 - .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 152 - 153 - for public_key in knot 154 - .database() 155 - .public_keys_for_did(&actor) 156 - .await 157 - .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))? 158 - { 159 - let mut key_parts = public_key.key.split_whitespace(); 160 - let key_data = match (key_parts.next(), key_parts.next()) { 161 - (Some(_), Some(key_data)) => key_data, 162 - _ => continue, 163 - }; 164 - 165 - let Ok(decoded) = Encoding.decode(key_data.as_bytes()) else { 166 - continue; 167 - }; 168 - 169 - let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &decoded); 170 - if digest.as_ref() == certificate_key_digest { 171 - let output = format!("Good signature for '{actor}'"); 172 - return Ok((StatusCode::OK, output)); 173 - } 174 - } 175 - 176 - let output = format!("Failed to find matching key for '{actor}'"); 177 - Err((StatusCode::FORBIDDEN, output)) 178 - } 179 - 180 - #[tracing::instrument(skip(knot, owner, rkey))] 181 - async fn hook_post_receive( 182 - State(knot): State<Knot>, 183 - Path(RepositoryKey { owner, rkey }): Path<RepositoryKey>, 184 - ActorDid(actor): ActorDid, 185 - body: String, 186 - ) -> Result<impl IntoResponse, XrpcError> { 187 - let repo_key = RepositoryKey { owner, rkey }; 188 - 189 - // Our hook refers to the repository using DID and rkey, but Tangled needs DID and name, so we 190 - // need to lookup the repository's name in the database. 191 - let (_, repo_name) = knot 192 - .database() 193 - .resolve_repository(&repo_key.owner, repo_key.rkey()) 194 - .await 195 - .map_err(errors::RepoNotFound)? 196 - .ok_or(errors::RepoNotFound(""))?; 197 - 198 - let repo = knot 199 - .open_repository(&repo_key) 200 - .await 201 - .map_err(errors::RepoNotFound)?; 202 - 203 - let default_ref = { 204 - let repository = repo.to_thread_local(); 205 - repository 206 - .head()? 207 - .referent_name() 208 - .ok_or(errors::HeadDetached)? 209 - .to_string() 210 - }; 211 - 212 - for line in body.lines() { 213 - let mut parts = line.split_whitespace(); 214 - let (old_sha, new_sha, refname) = 215 - match (parts.next(), parts.next(), parts.next(), parts.next()) { 216 - (Some(old_sha), Some(new_sha), Some(refname), None) => (old_sha, new_sha, refname), 217 - _ => panic!(), 218 - }; 219 - 220 - let old_sha = old_sha 221 - .parse() 222 - .expect("git should not output an invalid object ID"); 223 - let new_sha = new_sha 224 - .parse() 225 - .expect("git should not output an invalid object ID"); 226 - 227 - let commits = { 228 - let repo = repo.to_thread_local(); 229 - knot.pool() 230 - .spawn_fifo_async(move || repo.count_commits(&new_sha, &old_sha)) 231 - }; 232 - 233 - let languages = { 234 - let repo = repo.to_thread_local(); 235 - knot.pool() 236 - .spawn_fifo_async(move || repo.language_breakdown(&new_sha)) 237 - }; 238 - 239 - let (commits, languages) = tokio::join!(commits, languages); 240 - 241 - let meta = Meta { 242 - is_default_ref: refname == default_ref, 243 - commit_count: CommitCountBreakdown { 244 - by_email: commits 245 - .into_iter() 246 - .map(|(email, count)| CommitCount { email, count }) 247 - .collect(), 248 - }, 249 - lang_breakdown: LanguageBreakdown { 250 - inputs: languages 251 - .into_iter() 252 - .map(|(lang, size)| Language { lang, size }) 253 - .collect(), 254 - }, 255 - }; 256 - 257 - tracing::info!(?meta); 258 - 259 - let ref_update: RefUpdate = RefUpdate { 260 - r#ref: refname.to_string(), 261 - committer_did: actor.clone(), 262 - repo_did: repo_key.owner.clone(), 263 - repo_name: repo_name.clone(), 264 - old_sha: old_sha.into(), 265 - new_sha: old_sha.into(), 266 - meta, 267 - }; 268 - 269 - let ts = OffsetDateTime::now_utc(); 270 - let event = Event::RefUpdate(Arc::new(ref_update)); 271 - let id = knot 272 - .database() 273 - .insert_event( 274 - ts, 275 - &repo_key.owner, 276 - &repo_key.rkey, 277 - "sh.tangled.git.refUpdate", 278 - &event, 279 - ) 280 - .await 281 - .unwrap(); 282 - 283 - knot.send_event(id, ts, event).await; 284 - } 285 - 286 - Ok(StatusCode::NO_CONTENT) 287 - } 288 - 289 - #[tracing::instrument(skip(knot, repo_key))] 290 - async fn hook_post_update( 291 - State(knot): State<Knot>, 292 - Path(repo_key): Path<RepositoryKey>, 293 - ActorDid(actor): ActorDid, 294 - ) -> Result<impl IntoResponse, XrpcError> { 295 - let repo = knot 296 - .open_repository(&repo_key) 297 - .await 298 - .map_err(errors::RepoNotFound)? 299 - .to_thread_local(); 300 - 301 - let repository: TangledRepository = (knot.clone(), repo_key.clone(), repo).into(); 302 - 303 - // Schedule a maintenance run. 304 - knot.pool().spawn(move || { 305 - // We can do anything if this fails. 306 - let _ = run_maintenance(repo_key, repository).inspect_err(|error| tracing::error!(?error)); 307 - }); 308 - 309 - Ok(StatusCode::NO_CONTENT) 310 - } 311 - 312 - #[tracing::instrument(skip(repository))] 313 - fn run_maintenance( 314 - RepositoryKey { owner, rkey }: RepositoryKey, 315 - repository: TangledRepository, 316 - ) -> io::Result<()> { 317 - let output = repository 318 - .git() 319 - .args(["maintenance", "run"]) 320 - .stderr(Stdio::piped()) 321 - .spawn()? 322 - .wait_with_output()?; 323 - 324 - if !output.status.success() { 325 - let message = String::from_utf8_lossy(&output.stderr); 326 - tracing::error!(error = %message, "error running git maintenance"); 327 - } 328 - 329 - Ok(()) 330 - }
crates/knot/src/public.rs crates/gordian-knot/src/public.rs
-123
crates/knot/src/public/events.rs
··· 1 - use std::time::Duration; 2 - 3 - use atproto::tid::Tid; 4 - use axum::{ 5 - extract::{ 6 - Query, State, WebSocketUpgrade, 7 - ws::{Message, WebSocket}, 8 - }, 9 - http::StatusCode, 10 - response::IntoResponse, 11 - }; 12 - use futures_util::{SinkExt as _, StreamExt as _, TryStreamExt as _}; 13 - use serde::{Deserialize, Serialize}; 14 - use time::OffsetDateTime; 15 - use tokio::time::Instant; 16 - 17 - use crate::model::Knot; 18 - 19 - use super::xrpc::XrpcError; 20 - 21 - const KEEP_ALIVE: Duration = Duration::from_secs(45); 22 - 23 - #[derive(Serialize)] 24 - struct EventWrapper<'a, T> { 25 - nsid: &'a str, 26 - rkey: &'a str, 27 - event: &'a T, 28 - } 29 - 30 - #[derive(Deserialize)] 31 - pub struct EventsParameters { 32 - /// Nanoseconds from UNIX epoch. 33 - pub cursor: Option<i64>, 34 - } 35 - 36 - pub async fn handler( 37 - State(state): State<Knot>, 38 - Query(parameters): Query<EventsParameters>, 39 - ws: WebSocketUpgrade, 40 - ) -> Result<impl IntoResponse, XrpcError> { 41 - let cursor = parameters 42 - .cursor 43 - .and_then(|nanos| match nanos { 44 - 0 => None, 45 - _ => Some( 46 - OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).map_err(|error| { 47 - XrpcError::new(StatusCode::BAD_REQUEST, "InvalidCursor", error.to_string()) 48 - }), 49 - ), 50 - }) 51 - .transpose()?; 52 - 53 - Ok(ws.on_upgrade(move |socket| handle_socket(state, cursor, socket))) 54 - } 55 - 56 - async fn handle_socket(state: Knot, start_ts: Option<OffsetDateTime>, socket: WebSocket) { 57 - let mut keep_alive = tokio::time::interval(KEEP_ALIVE); 58 - keep_alive.tick().await; 59 - 60 - let mut events = state.subscribe_events(); 61 - let start = Instant::now(); 62 - 63 - let mut cursor = 0; 64 - let start_ts = start_ts.unwrap_or(OffsetDateTime::now_utc()); 65 - tracing::debug!(?start_ts, "new events listener"); 66 - 67 - let (mut sender, mut receiver) = socket.split(); 68 - 69 - let mut past_events = state.database().get_events(&start_ts); 70 - while let Some(Ok(event)) = past_events.next().await { 71 - cursor = event.id; 72 - let wrapper = EventWrapper { 73 - nsid: &event.collection, 74 - rkey: &Tid::from_datetime(event.ts, event.id.rem_euclid(1023).try_into().unwrap()) 75 - .to_string(), 76 - event: &event.record, 77 - }; 78 - 79 - let serialized = serde_json::to_string(&wrapper).unwrap(); 80 - if let Err(error) = sender.send(Message::text(serialized)).await { 81 - tracing::error!(?error, "failed to send event"); 82 - return; 83 - } 84 - } 85 - 86 - loop { 87 - let (id, ts, event) = tokio::select! { 88 - now = keep_alive.tick() => { 89 - let bytes = (now.duration_since(start)).as_secs().to_string().into(); 90 - if let Err(error) = sender.send(Message::Ping(bytes)).await { 91 - tracing::error!(?error, "failed to send ping"); 92 - break; 93 - } 94 - continue; 95 - } 96 - Ok(Some(message)) = receiver.try_next() => { 97 - tracing::trace!(?message); 98 - continue; 99 - } 100 - Ok(event) = events.recv() => event, 101 - else => break, 102 - }; 103 - 104 - if id < cursor { 105 - tracing::debug!(?id, "skipping event, client has already seen"); 106 - continue; 107 - } 108 - 109 - let wrapper = EventWrapper { 110 - nsid: event.collection(), 111 - rkey: &Tid::from_datetime(ts, id.rem_euclid(1023).try_into().unwrap()).to_string(), 112 - event: &event, 113 - }; 114 - 115 - let serialized = serde_json::to_string(&wrapper).unwrap(); 116 - if let Err(error) = sender.send(Message::text(serialized)).await { 117 - tracing::error!(?error, "failed to send event"); 118 - return; 119 - } 120 - 121 - cursor = id; 122 - } 123 - }
crates/knot/src/public/git.rs crates/gordian-knot/src/public/git.rs
-113
crates/knot/src/public/git/authorization.rs
··· 1 - use atproto::Nsid; 2 - use auth::{ 3 - IntoVerificationKey, OpenSshKey, 4 - jwt::{Claims, Token, decode}, 5 - }; 6 - use axum::{ 7 - extract::{FromRef, FromRequestParts}, 8 - http::{header::AUTHORIZATION, request::Parts}, 9 - }; 10 - use identity::Resolver; 11 - use time::OffsetDateTime; 12 - 13 - use crate::{ 14 - model::Knot, 15 - nsid::SH_TANGLED_REPO_GITRECEIVEPACK, 16 - services::authorization::{ 17 - AuthorizationClaimsStore as _, Verification, VerificationError, extract_token, 18 - }, 19 - }; 20 - 21 - use super::Error; 22 - 23 - #[derive(Debug)] 24 - struct GitVerification; 25 - 26 - impl Verification for GitVerification { 27 - const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_GITRECEIVEPACK; 28 - } 29 - 30 - #[derive(Clone, Debug)] 31 - pub struct GitAuthorization(pub Claims); 32 - 33 - impl<S: Sync> FromRequestParts<S> for GitAuthorization 34 - where 35 - Knot: FromRef<S>, 36 - Resolver: FromRef<S>, 37 - { 38 - type Rejection = Error; 39 - 40 - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 41 - let knot = Knot::from_ref(state); 42 - let resolver = Resolver::from_ref(state); 43 - let now = OffsetDateTime::now_utc().unix_timestamp(); 44 - 45 - let credential = extract_token(parts, AUTHORIZATION, "bearer").ok_or( 46 - Error::unauthorized(&knot, "inter-service authorization required"), 47 - )?; 48 - 49 - let unverified_token = Token::decode_unverified(credential) 50 - .map_err(|_| Error::unauthorized(&knot, "inter-service authorization required"))?; 51 - 52 - // Before performing a relatively expensive DID look-up, ensure the token 53 - // claims are valid. 54 - let unverified_claims = unverified_token.claims; 55 - GitVerification::verify(&knot, now, knot.instance(), &unverified_claims) 56 - .await 57 - .map_err(|error| match error { 58 - // Git re-uses the token from the credential helper for each request in a single push. 59 - // 60 - // Returning 'Forbidden' here will make git abort. Instead, we return an Unauthorized 61 - // which will force git to get a new token from the credential helper. 62 - VerificationError::Reused => Error::unauthorized(&knot, "authorization re-used"), 63 - error => Error::forbidden(&knot, error.to_string()), 64 - })?; 65 - 66 - // Resolve the DID document for the claimed issuer, extract and parse 67 - // the verification methods into public keys. 68 - 69 - let (resolved_did, doc) = resolver 70 - .resolve(unverified_claims.iss.as_str()) 71 - .await 72 - .map_err(|error| Error::forbidden(&knot, error.to_string()))?; 73 - 74 - assert_eq!(unverified_claims.iss, resolved_did); 75 - 76 - let verification_keys = doc 77 - .verification_method 78 - .into_iter() 79 - .filter_map(|vm| vm.into_verification_key().ok()); 80 - 81 - // Try to decode and verify the JWT using any one of the verification keys 82 - // we have for the DID. 83 - for verification_key in verification_keys { 84 - if let Ok(token) = decode::<Claims>(credential, &verification_key) { 85 - // Store the JWT so it cannot be re-used within the claim period. 86 - knot.store_claims(token.claims.clone(), now).await?; 87 - return Ok(Self(token.claims)); 88 - } 89 - } 90 - 91 - // Read the 'sh.tangled.publicKey' records the knot has associated 92 - // with claimed issuer. 93 - let public_keys = knot 94 - .database() 95 - .public_keys_for_did(&unverified_claims.iss) 96 - .await 97 - .unwrap_or_default() 98 - .into_iter() 99 - .filter_map(|public_key| OpenSshKey(public_key.key).into_verification_key().ok()); 100 - 101 - // Try to decode and verify the JWT using any one of the public keys 102 - // we have for the DID. 103 - for verification_key in public_keys { 104 - if let Ok(token) = decode::<Claims>(credential, &verification_key) { 105 - // Store the JWT so it cannot be re-used within the claim period. 106 - knot.store_claims(token.claims.clone(), now).await?; 107 - return Ok(Self(token.claims)); 108 - } 109 - } 110 - 111 - Err(Error::forbidden(&knot, "No valid authorization found"))? 112 - } 113 - }
crates/knot/src/public/git/error.rs crates/gordian-knot/src/public/git/error.rs
crates/knot/src/public/git/protocol.rs crates/gordian-knot/src/public/git/protocol.rs
-255
crates/knot/src/public/xrpc.rs
··· 1 - use crate::model::{ 2 - Knot, errors, 3 - repository::{BlobError, RevspecError, TreeError}, 4 - }; 5 - use axum::{ 6 - Json, Router, 7 - extract::{FromRef, FromRequestParts}, 8 - http::StatusCode, 9 - response::IntoResponse, 10 - }; 11 - use identity::Resolver; 12 - use serde::de::DeserializeOwned; 13 - use std::borrow::Cow; 14 - 15 - pub mod sh_tangled; 16 - 17 - pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> 18 - where 19 - Knot: FromRef<S>, 20 - Resolver: FromRef<S>, 21 - { 22 - Router::<S>::new() 23 - .without_v07_checks() 24 - .merge(sh_tangled::owner()) 25 - .merge(sh_tangled::knot::version()) 26 - .merge(sh_tangled::repo::archive()) 27 - .merge(sh_tangled::repo::blob()) 28 - .merge(sh_tangled::repo::branch()) 29 - .merge(sh_tangled::repo::branches()) 30 - .merge(sh_tangled::repo::compare()) 31 - .merge(sh_tangled::repo::create()) 32 - .merge(sh_tangled::repo::delete()) 33 - .merge(sh_tangled::repo::diff()) 34 - .merge(sh_tangled::repo::get_default_branch()) 35 - .merge(sh_tangled::repo::merge_check()) 36 - .merge(sh_tangled::repo::languages()) 37 - .merge(sh_tangled::repo::log()) 38 - .merge(sh_tangled::repo::tags()) 39 - .merge(sh_tangled::repo::tree()) 40 - .merge(sh_tangled::repo::set_default_branch()) 41 - } 42 - 43 - pub type XrpcResult<T> = Result<XrpcResponse<T>, XrpcError>; 44 - 45 - pub struct XrpcResponse<T> { 46 - pub response: T, 47 - pub immutable: bool, 48 - } 49 - 50 - impl From<()> for XrpcResponse<()> { 51 - fn from(_: ()) -> Self { 52 - Self { 53 - response: (), 54 - immutable: false, 55 - } 56 - } 57 - } 58 - 59 - impl<T> From<Json<T>> for XrpcResponse<Json<T>> 60 - where 61 - Json<T>: IntoResponse, 62 - { 63 - fn from(value: Json<T>) -> Self { 64 - Self { 65 - response: value, 66 - immutable: false, 67 - } 68 - } 69 - } 70 - 71 - impl<T> IntoResponse for XrpcResponse<T> 72 - where 73 - T: IntoResponse, 74 - { 75 - fn into_response(self) -> axum::response::Response { 76 - use axum::http::header::{CACHE_CONTROL, HeaderValue}; 77 - 78 - let Self { 79 - response, 80 - immutable, 81 - } = self; 82 - 83 - let mut response = response.into_response(); 84 - if immutable { 85 - let headers = response.headers_mut(); 86 - headers.insert( 87 - CACHE_CONTROL, 88 - HeaderValue::from_static("public, immutable, s-maxage=604800"), 89 - ); 90 - } 91 - 92 - response 93 - } 94 - } 95 - 96 - #[derive(Debug, Default)] 97 - pub struct XrpcError { 98 - pub status: StatusCode, 99 - pub error: Cow<'static, str>, 100 - pub message: Cow<'static, str>, 101 - } 102 - 103 - impl XrpcError { 104 - pub fn new( 105 - status: impl Into<StatusCode>, 106 - error: &'static str, 107 - message: impl Into<Cow<'static, str>>, 108 - ) -> Self { 109 - Self { 110 - status: status.into(), 111 - error: Cow::Borrowed(error), 112 - message: message.into(), 113 - } 114 - } 115 - 116 - pub fn from_static( 117 - status: impl Into<StatusCode>, 118 - error: &'static str, 119 - message: &'static str, 120 - ) -> Self { 121 - Self { 122 - status: status.into(), 123 - error: Cow::Borrowed(error), 124 - message: Cow::Borrowed(message), 125 - } 126 - } 127 - } 128 - 129 - impl std::fmt::Display for XrpcError { 130 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 - write!(f, "{self:?}") 132 - } 133 - } 134 - 135 - impl IntoResponse for XrpcError { 136 - fn into_response(self) -> axum::response::Response { 137 - #[derive(serde::Serialize)] 138 - struct Body<'a> { 139 - error: &'a str, 140 - message: &'a str, 141 - } 142 - 143 - let body = Body { 144 - error: &self.error, 145 - message: &self.message, 146 - }; 147 - 148 - (self.status, Json(body)).into_response() 149 - } 150 - } 151 - 152 - impl<T: std::fmt::Display + std::fmt::Debug> From<(StatusCode, T)> for XrpcError { 153 - fn from((status, error): (StatusCode, T)) -> Self { 154 - Self { 155 - status, 156 - error: Cow::Owned(format!("{error:?}")), 157 - message: Cow::Owned(format!("{error}")), 158 - } 159 - } 160 - } 161 - 162 - macro_rules! ise { 163 - ($t:ty, $status:expr, $typ:literal) => { 164 - impl From<$t> for XrpcError { 165 - fn from(value: $t) -> Self { 166 - Self { 167 - status: $status, 168 - error: Cow::Borrowed($typ), 169 - message: Cow::Owned(format!("{value}").replace('"', "\'")), 170 - } 171 - } 172 - } 173 - }; 174 - ($t:ty, $status:expr) => { 175 - impl From<$t> for XrpcError { 176 - fn from(value: $t) -> Self { 177 - ($status, value).into() 178 - } 179 - } 180 - }; 181 - ($t:ty) => { 182 - ise!($t, StatusCode::INTERNAL_SERVER_ERROR); 183 - }; 184 - } 185 - 186 - ise!(std::io::Error); 187 - ise!(gix::diff::blob::platform::set_resource::Error); 188 - ise!(gix::diff::options::init::Error); 189 - ise!(gix::repository::diff_resource_cache::Error); 190 - ise!(gix::object::commit::Error); 191 - ise!(gix::objs::decode::Error); 192 - ise!(gix::object::find::existing::Error); 193 - ise!(gix::object::find::existing::with_conversion::Error); 194 - ise!(gix::object::tree::diff::for_each::Error); 195 - ise!(gix::reference::iter::Error); 196 - ise!(gix::reference::iter::init::Error); 197 - ise!(gix::reference::head_tree::Error); 198 - ise!(gix::reference::find::existing::Error, StatusCode::NOT_FOUND); 199 - ise!(gix::repository::commit_graph_if_enabled::Error); 200 - 201 - /// Wraps [`axum::extract::Query`] to customize the rejection type to [`XrpcError`]. 202 - /// 203 - pub struct XrpcQuery<T>(pub T); 204 - 205 - impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> { 206 - type Rejection = XrpcError; 207 - 208 - async fn from_request_parts( 209 - parts: &mut axum::http::request::Parts, 210 - state: &S, 211 - ) -> Result<Self, Self::Rejection> { 212 - use axum::extract::Query; 213 - 214 - let Query(params) = Query::from_request_parts(parts, state) 215 - .await 216 - .map_err(errors::InvalidRequest)?; 217 - 218 - Ok(Self(params)) 219 - } 220 - } 221 - 222 - impl From<RevspecError> for XrpcError { 223 - fn from(value: RevspecError) -> Self { 224 - Self { 225 - status: StatusCode::NOT_FOUND, 226 - error: "RefNotFound".into(), 227 - message: format!("{value}").into(), 228 - } 229 - } 230 - } 231 - 232 - impl From<BlobError> for XrpcError { 233 - fn from(value: BlobError) -> Self { 234 - let (status, error) = match value { 235 - BlobError::PathLookupError(_) => (StatusCode::NOT_FOUND, "FileNotFound"), 236 - BlobError::FileNotFound => (StatusCode::NOT_FOUND, "FileNotFound"), 237 - BlobError::NotABlob => (StatusCode::BAD_REQUEST, "InvalidRequest"), 238 - }; 239 - Self { 240 - status, 241 - error: error.into(), 242 - message: format!("{value}").into(), 243 - } 244 - } 245 - } 246 - 247 - impl From<TreeError> for XrpcError { 248 - fn from(value: TreeError) -> Self { 249 - Self { 250 - status: StatusCode::NOT_FOUND, 251 - error: "FileNotFound".into(), 252 - message: format!("{value}").into(), 253 - } 254 - } 255 - }
-39
crates/knot/src/public/xrpc/sh_tangled.rs
··· 1 - use crate::model::Knot; 2 - 3 - pub mod knot; 4 - pub mod repo; 5 - 6 - /// Get the owner of a service. 7 - /// 8 - /// <https://tangled.org/tangled.org/core/blob/master/lexicons/owner.json> 9 - /// 10 - pub fn owner<S>() -> axum::Router<S> 11 - where 12 - S: Clone + Send + Sync + 'static, 13 - Knot: axum::extract::FromRef<S>, 14 - { 15 - use impl_owner::{LXM, owner_query}; 16 - axum::Router::new().route(LXM, axum::routing::get(owner_query)) 17 - } 18 - 19 - mod impl_owner { 20 - use axum::{ 21 - Json, 22 - extract::{FromRef, State}, 23 - response::{IntoResponse, Response}, 24 - }; 25 - 26 - use crate::model::Knot; 27 - 28 - pub const LXM: &str = "/sh.tangled.owner"; 29 - 30 - pub async fn owner_query<S>(State(knot): State<Knot>) -> Response 31 - where 32 - Knot: FromRef<S>, 33 - { 34 - Json(lexicon::sh_tangled::owner::Output { 35 - owner: knot.owner(), 36 - }) 37 - .into_response() 38 - } 39 - }
-27
crates/knot/src/public/xrpc/sh_tangled/knot.rs
··· 1 - use crate::model::Knot; 2 - 3 - /// Get the version of a knot. 4 - /// 5 - /// <https://tangled.org/tangled.org/core/blob/master/lexicons/knot/version.json> 6 - /// 7 - pub fn version<S>() -> axum::Router<S> 8 - where 9 - S: Clone + Send + Sync + 'static, 10 - Knot: axum::extract::FromRef<S>, 11 - { 12 - use impl_version::{LXM, handle}; 13 - axum::Router::new().route(LXM, axum::routing::get(handle)) 14 - } 15 - 16 - mod impl_version { 17 - use axum::Json; 18 - use lexicon::sh_tangled::knot::version::Output; 19 - 20 - pub const LXM: &str = "/sh.tangled.knot.version"; 21 - 22 - pub async fn handle() -> Json<Output> { 23 - Json(Output { 24 - version: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).into(), 25 - }) 26 - } 27 - }
-60
crates/knot/src/public/xrpc/sh_tangled/repo.rs
··· 1 - use identity::Resolver; 2 - 3 - use crate::model::Knot; 4 - 5 - mod impl_archive; 6 - mod impl_blob; 7 - mod impl_branch; 8 - mod impl_branches; 9 - mod impl_compare; 10 - mod impl_create; 11 - mod impl_delete; 12 - mod impl_diff; 13 - mod impl_get_default_branch; 14 - mod impl_languages; 15 - mod impl_log; 16 - mod impl_merge_check; 17 - mod impl_set_default_branch; 18 - mod impl_tags; 19 - mod impl_tree; 20 - 21 - macro_rules! impl_xrpc { 22 - (QUERY, $name:ident, $module:ident) => { 23 - pub fn $name<S>() -> axum::Router<S> 24 - where 25 - S: Clone + Send + Sync + 'static, 26 - Knot: axum::extract::FromRef<S>, 27 - { 28 - use $module::{LXM, handle}; 29 - axum::Router::new().route(LXM, axum::routing::get(handle)) 30 - } 31 - }; 32 - (PROCEDURE, $name:ident, $module:ident) => { 33 - pub fn $name<S>() -> axum::Router<S> 34 - where 35 - S: Clone + Send + Sync + 'static, 36 - Knot: axum::extract::FromRef<S>, 37 - Resolver: axum::extract::FromRef<S>, 38 - { 39 - use $module::{LXM, handle}; 40 - axum::Router::new().route(LXM, axum::routing::post(handle)) 41 - } 42 - }; 43 - } 44 - 45 - impl_xrpc!(QUERY, archive, impl_archive); 46 - impl_xrpc!(QUERY, blob, impl_blob); 47 - impl_xrpc!(QUERY, branch, impl_branch); 48 - impl_xrpc!(QUERY, branches, impl_branches); 49 - impl_xrpc!(QUERY, compare, impl_compare); 50 - impl_xrpc!(QUERY, diff, impl_diff); 51 - impl_xrpc!(QUERY, get_default_branch, impl_get_default_branch); 52 - impl_xrpc!(QUERY, languages, impl_languages); 53 - impl_xrpc!(QUERY, log, impl_log); 54 - impl_xrpc!(QUERY, tags, impl_tags); 55 - impl_xrpc!(QUERY, tree, impl_tree); 56 - 57 - impl_xrpc!(PROCEDURE, create, impl_create); 58 - impl_xrpc!(PROCEDURE, delete, impl_delete); 59 - impl_xrpc!(PROCEDURE, merge_check, impl_merge_check); 60 - impl_xrpc!(PROCEDURE, set_default_branch, impl_set_default_branch);
-135
crates/knot/src/public/xrpc/sh_tangled/repo/impl_archive.rs
··· 1 - use crate::model::Knot; 2 - use crate::model::errors; 3 - use crate::model::repository::ResolvedRevspec; 4 - use crate::model::repository::TangledRepository; 5 - use crate::public::xrpc::XrpcError; 6 - use crate::public::xrpc::XrpcQuery; 7 - use crate::types::repository_path::RepositoryPath; 8 - use axum::extract::State; 9 - use axum::http::HeaderMap; 10 - use axum::http::HeaderValue; 11 - use axum::http::StatusCode; 12 - use axum::response::IntoResponse; 13 - use axum_extra::body::AsyncReadBody; 14 - use git_service::util::SetOptionArg as _; 15 - use lexicon::sh_tangled::repo::archive::Format; 16 - use lexicon::sh_tangled::repo::archive::Input; 17 - use std::process::Stdio; 18 - use std::time::Duration; 19 - 20 - pub const LXM: &str = "/sh.tangled.repo.archive"; 21 - 22 - const SPAWN_WAIT: Duration = Duration::from_millis(100); 23 - 24 - #[tracing::instrument(target = "sh_tangled::repo::archive", skip(knot, repository), err)] 25 - pub async fn handle( 26 - State(knot): State<Knot>, 27 - XrpcQuery(Input { 28 - repo, 29 - rev, 30 - format, 31 - prefix, 32 - }): XrpcQuery<Input>, 33 - repository: TangledRepository, 34 - ) -> Result<impl IntoResponse, XrpcError> { 35 - use crate::model::repository::ResolveRevspec as _; 36 - use axum::http::header; 37 - 38 - let repo_path: RepositoryPath = repo 39 - .parse() 40 - .expect("Repository extractor should have validated repo parameter"); 41 - 42 - let (mut child, immutable, link) = { 43 - let ResolvedRevspec { commit, immutable } = 44 - repository.resolve_revspec(&Some(rev.as_str()))?; 45 - 46 - let rev = commit.id.to_string(); 47 - let immutable_link = immutable_link( 48 - &format!("https://{}/xrpc{LXM}", knot.instance_ident()), 49 - &repo, 50 - &rev, 51 - prefix.as_deref(), 52 - format, 53 - ); 54 - 55 - let mut command: tokio::process::Command = repository.git().into(); 56 - let child = command 57 - .arg("archive") 58 - .arg(format!("--format={format}")) 59 - .option_arg(prefix.map(|prefix| format!("--prefix={prefix}/"))) 60 - .arg(&rev) 61 - .stdout(Stdio::piped()) 62 - .stderr(Stdio::piped()) 63 - .spawn()?; 64 - 65 - (child, immutable, immutable_link) 66 - }; 67 - 68 - // Allow some time for the spawned command to run. 69 - tokio::time::sleep(SPAWN_WAIT).await; 70 - if let Ok(Some(exit_status)) = child.try_wait() 71 - && !exit_status.success() 72 - { 73 - tracing::error!(?exit_status, "failed to spawn git-archive"); 74 - let output = child.wait_with_output().await.map_err(errors::Internal)?; 75 - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); 76 - return Err(XrpcError { 77 - status: StatusCode::INTERNAL_SERVER_ERROR, 78 - error: "ArchiveError".into(), 79 - message: message.into(), 80 - }); 81 - } 82 - 83 - let stdout = child 84 - .stdout 85 - .take() 86 - .expect("Child process stdout handle should be set"); 87 - 88 - let mut headers = HeaderMap::new(); 89 - if immutable { 90 - headers.insert( 91 - header::CACHE_CONTROL, 92 - HeaderValue::from_static("public, immutable, s-maxage=604800"), 93 - ); 94 - } 95 - 96 - let repo_name = repo_path.name; 97 - let safe_ref = rev.replace('/', "-"); 98 - headers.insert( 99 - header::CONTENT_DISPOSITION, 100 - HeaderValue::from_str(&format!( 101 - "attachment; filename=\"{repo_name}-{safe_ref}.{format}\"" 102 - )) 103 - .map_err(errors::Internal)?, 104 - ); 105 - headers.insert( 106 - header::CONTENT_TYPE, 107 - HeaderValue::from_static(format.as_content_type()), 108 - ); 109 - headers.insert( 110 - header::LINK, 111 - HeaderValue::from_str(&format!("<{link}>; rel=\"immutable\"")).map_err(errors::Internal)?, 112 - ); 113 - 114 - Ok((headers, AsyncReadBody::new(stdout)).into_response()) 115 - } 116 - 117 - fn immutable_link( 118 - base: &str, 119 - repo: &str, 120 - revision: &str, 121 - prefix: Option<&str>, 122 - format: Format, 123 - ) -> url::Url { 124 - let mut url = url::Url::parse(&base).expect("Base URL should be valid"); 125 - { 126 - let mut query = url.query_pairs_mut(); 127 - query.append_pair("repo", &repo); 128 - query.append_pair("format", format.as_str()); 129 - query.append_pair("ref", &revision); 130 - if let Some(prefix) = &prefix { 131 - query.append_pair("prefix", prefix); 132 - } 133 - } 134 - url 135 - }
-132
crates/knot/src/public/xrpc/sh_tangled/repo/impl_blob.rs
··· 1 - use std::path::PathBuf; 2 - 3 - use axum::{ 4 - Json, 5 - extract::State, 6 - http::{HeaderMap, HeaderValue, StatusCode}, 7 - response::{IntoResponse, Response}, 8 - }; 9 - use data_encoding::BASE64; 10 - use lexicon::sh_tangled::repo::blob::{Encoding, Input, Output, Submodule}; 11 - use mimetype_detector::MimeType; 12 - use reqwest::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG}; 13 - use tokio_rayon::AsyncThreadPool as _; 14 - 15 - use crate::{ 16 - extractors::IfNoneMatch, 17 - model::{ 18 - Knot, 19 - repository::{ResolveRevspec as _, ResolvedRevspec, TangledRepository}, 20 - }, 21 - public::xrpc::{XrpcError, XrpcQuery}, 22 - }; 23 - 24 - pub const LXM: &str = "/sh.tangled.repo.blob"; 25 - 26 - #[tracing::instrument( 27 - target = "sh_tangled::repo::blob", 28 - skip(knot, if_none_match, repository), 29 - err 30 - )] 31 - pub async fn handle( 32 - State(knot): State<Knot>, 33 - if_none_match: Option<IfNoneMatch>, 34 - XrpcQuery(Input { 35 - repo, 36 - rev, 37 - path, 38 - raw, 39 - }): XrpcQuery<Input>, 40 - repository: TangledRepository, 41 - ) -> Result<Response, XrpcError> { 42 - knot.pool() 43 - .spawn_async(move || { 44 - let ResolvedRevspec { commit, immutable } = 45 - repository.resolve_revspec(&Some(rev.as_str()))?; 46 - 47 - // Use the tree object ID as an ETag. 48 - // 49 - // 1. If the blob content has changed, the blob object ID will be different, and 50 - // therefore the tree object ID will also be different. 51 - // 52 - // 2. Using the tree object ID avoids searching the tree for the blob path. 53 - 54 - let tree = repository.get_tree(&commit)?; 55 - if if_none_match.is_some_and(|etags| etags.contains(&tree.id.to_string())) { 56 - return Ok(StatusCode::NOT_MODIFIED.into_response()); 57 - } 58 - 59 - let mut response = match repository.submodule(&path) { 60 - Some(submodule) => Json(blob_submodule(rev, path, submodule)).into_response(), 61 - None => { 62 - let buffer = repository.get_blob(&tree, &path)?; 63 - let mime_type = mimetype_detector::detect(&buffer); 64 - match raw { 65 - true => blob_raw(mime_type, buffer), 66 - false => Json(blob_json(rev, path, mime_type, buffer)).into_response(), 67 - } 68 - } 69 - }; 70 - 71 - let headers = response.headers_mut(); 72 - if immutable { 73 - headers.insert( 74 - CACHE_CONTROL, 75 - HeaderValue::from_static("public, immutable, s-maxage=604800"), 76 - ); 77 - } 78 - 79 - headers.insert( 80 - ETAG, 81 - HeaderValue::from_str(&format!("\"{}\"", tree.id)) 82 - .expect("Hex-string should be a valid header value"), 83 - ); 84 - 85 - Ok(response) 86 - }) 87 - .await 88 - } 89 - 90 - fn blob_submodule(rev: String, path: PathBuf, submodule: Submodule) -> Output { 91 - Output { 92 - rev, 93 - path, 94 - content: None, 95 - encoding: None, 96 - size: None, 97 - is_binary: None, 98 - mime_type: None, 99 - submodule: Some(submodule), 100 - last_commit: None, 101 - } 102 - } 103 - 104 - fn blob_json(rev: String, path: PathBuf, mime_type: &MimeType, buffer: Vec<u8>) -> Output { 105 - let size = buffer.len(); 106 - let (content, encoding, is_binary) = match String::from_utf8(buffer) { 107 - Ok(content) => (content, Encoding::Utf8, false), 108 - Err(error) => (BASE64.encode(error.as_bytes()), Encoding::Base64, true), 109 - }; 110 - 111 - Output { 112 - rev, 113 - path, 114 - encoding: Some(encoding), 115 - size: Some(size), 116 - content: Some(content), 117 - mime_type: Some(mime_type.mime().into()), 118 - is_binary: Some(is_binary), 119 - submodule: None, 120 - last_commit: None, 121 - } 122 - } 123 - 124 - fn blob_raw(mime_type: &MimeType, buffer: Vec<u8>) -> Response { 125 - let mut headers = HeaderMap::new(); 126 - headers.insert( 127 - CONTENT_TYPE, 128 - HeaderValue::from_str(mime_type.mime()).expect("MIME type should be a valid header value"), 129 - ); 130 - 131 - (headers, buffer).into_response() 132 - }
-21
crates/knot/src/public/xrpc/sh_tangled/repo/impl_branch.rs
··· 1 - use axum::{Json, extract::State}; 2 - use lexicon::sh_tangled::repo::branch::{Input, Output}; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 9 - 10 - pub const LXM: &str = "/sh.tangled.repo.branch"; 11 - 12 - #[tracing::instrument(target = "sh_tangled::repo::branch", skip(knot, repository), err)] 13 - pub async fn handle( 14 - State(knot): State<Knot>, 15 - XrpcQuery(params): XrpcQuery<Input>, 16 - repository: TangledRepository, 17 - ) -> XrpcResult<Json<Output>> { 18 - knot.pool() 19 - .spawn_async(move || repository.branch(params)) 20 - .await 21 - }
crates/knot/src/public/xrpc/sh_tangled/repo/impl_branches.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_branches.rs
crates/knot/src/public/xrpc/sh_tangled/repo/impl_compare.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_compare.rs
-85
crates/knot/src/public/xrpc/sh_tangled/repo/impl_create.rs
··· 1 - use atproto::Nsid; 2 - use axum::{Json, extract::State, http::StatusCode}; 3 - use lexicon::{ 4 - com::atproto::repo::list_records::Record, 5 - sh_tangled::repo::{Repo, create::Input}, 6 - }; 7 - 8 - use crate::{ 9 - model::{Knot, errors}, 10 - nsid::SH_TANGLED_REPO_CREATE, 11 - public::xrpc::{XrpcError, XrpcResult}, 12 - services::{ 13 - atrepo, 14 - authorization::{Authorization, Verification}, 15 - }, 16 - types::RecordKey, 17 - }; 18 - 19 - pub const LXM: &str = "/sh.tangled.repo.create"; 20 - 21 - #[derive(Debug, Default)] 22 - pub struct CreateVerification; 23 - 24 - impl Verification for CreateVerification { 25 - const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_CREATE; 26 - } 27 - 28 - #[tracing::instrument(target = "sh_tangled::repo::create", skip(knot, authorization), err)] 29 - pub async fn handle( 30 - State(knot): State<Knot>, 31 - authorization: Authorization<CreateVerification>, 32 - Json(params): Json<Input>, 33 - ) -> XrpcResult<()> { 34 - use crate::services::rbac::{Action, Policy, PolicyResult::*, RepositoryCreatePolicy}; 35 - 36 - let claims = authorization.claims(); 37 - let policy = RepositoryCreatePolicy; 38 - let can_create = policy 39 - .evaluate_access( 40 - &claims.iss.as_ref(), 41 - &Action::RepositoryCreate, 42 - &knot, 43 - &knot, 44 - ) 45 - .await; 46 - 47 - if !matches!(can_create, Granted) { 48 - return Err(errors::Forbidden(format!( 49 - "'{}' does not have permission to create repositories on this knot", 50 - claims.iss 51 - )))?; 52 - } 53 - 54 - // Fetch repository record from pds. 55 - let response = atrepo::fetch_record_bytes( 56 - knot.resolver(), 57 - knot.http(), 58 - &claims.iss, 59 - "sh.tangled.repo", 60 - &params.rkey, 61 - ) 62 - .await 63 - .inspect_err(|error| tracing::error!(?error, did = %claims.iss, rkey = %params.rkey, "unable to fetch record")) 64 - .map_err(errors::RepoError)?; 65 - 66 - let record = serde_json::from_slice::<Record>(&response) 67 - .inspect_err(|error| tracing::error!(?error)) 68 - .map_err(errors::RepoError)?; 69 - 70 - let repo: Repo = serde_json::from_str(record.value.get()).map_err(|error| { 71 - XrpcError::new( 72 - StatusCode::INTERNAL_SERVER_ERROR, 73 - "LexiconError", 74 - error.to_string(), 75 - ) 76 - })?; 77 - 78 - let rec = RecordKey::try_from(&record).map_err(errors::InvalidRequest)?; 79 - knot.create_repo(&rec, &repo) 80 - .await 81 - .inspect_err(|error| tracing::error!(?error, "failed to create repository")) 82 - .map_err(errors::Internal)?; 83 - 84 - Ok(().into()) 85 - }
-68
crates/knot/src/public/xrpc/sh_tangled/repo/impl_delete.rs
··· 1 - use atproto::{Nsid, tid::Tid}; 2 - use axum::{Json, extract::State}; 3 - use lexicon::sh_tangled::repo::delete::Input; 4 - 5 - use crate::{ 6 - model::{Knot, errors}, 7 - nsid::SH_TANGLED_REPO_DELETE, 8 - public::xrpc::XrpcResult, 9 - services::authorization::{Authorization, Verification}, 10 - types::RecordKey, 11 - }; 12 - 13 - pub const LXM: &str = "/sh.tangled.repo.delete"; 14 - 15 - #[derive(Debug, Default)] 16 - pub struct DeleteVerification; 17 - 18 - impl Verification for DeleteVerification { 19 - const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_DELETE; 20 - } 21 - 22 - #[tracing::instrument(target = "sh_tangled::repo::delete", skip(knot, authorization), err)] 23 - pub async fn handle( 24 - State(knot): State<Knot>, 25 - authorization: Authorization<DeleteVerification>, 26 - Json(params): Json<Input>, 27 - ) -> XrpcResult<()> { 28 - use crate::services::rbac::{ 29 - Action, Policy, PolicyResult::*, RepositoryDeletePolicy, RepositoryRef, 30 - }; 31 - 32 - let claims = authorization.claims(); 33 - let policy = RepositoryDeletePolicy; 34 - let repository = RepositoryRef::new(&params.did, &params.rkey); 35 - let can_delete = policy 36 - .evaluate_access( 37 - &claims.iss.as_ref(), 38 - &Action::RepositoryDelete, 39 - &repository, 40 - &knot, 41 - ) 42 - .await; 43 - 44 - if !matches!(can_delete, Granted) { 45 - return Err(errors::Forbidden(format!( 46 - "'{}' does not have permission to delete repository '{}/{}'", 47 - claims.iss, params.did, params.rkey 48 - )))?; 49 - } 50 - 51 - // @NB Alternative strategy: 52 - // 53 - // 1. Check delete permissions. 54 - // 2. Immediately return OK. 55 - // 3. Wait for delete event from Jetstream/Firehose. 56 - 57 - let rec = RecordKey { 58 - did: &params.did, 59 - collection: "sh.tangled.repo", 60 - rkey: &params.rkey, 61 - rev: &Tid::MAX.to_string(), 62 - cid: "", 63 - }; 64 - 65 - knot.delete_repo(&rec).await.map_err(errors::Internal)?; 66 - 67 - Ok(().into()) 68 - }
crates/knot/src/public/xrpc/sh_tangled/repo/impl_diff.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_diff.rs
-25
crates/knot/src/public/xrpc/sh_tangled/repo/impl_get_default_branch.rs
··· 1 - use axum::{Json, extract::State}; 2 - use lexicon::sh_tangled::repo::get_default_branch::{Input, Output}; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 9 - 10 - pub const LXM: &str = "/sh.tangled.repo.getDefaultBranch"; 11 - 12 - #[tracing::instrument( 13 - target = "sh_tangled::repo::getDefaultBranch", 14 - skip(knot, repository), 15 - err 16 - )] 17 - pub async fn handle( 18 - State(knot): State<Knot>, 19 - XrpcQuery(params): XrpcQuery<Input>, 20 - repository: TangledRepository, 21 - ) -> XrpcResult<Json<Output>> { 22 - knot.pool() 23 - .spawn_async(move || repository.get_default_branch(params)) 24 - .await 25 - }
-21
crates/knot/src/public/xrpc/sh_tangled/repo/impl_languages.rs
··· 1 - use axum::{Json, extract::State}; 2 - use lexicon::sh_tangled::repo::languages::{Input, Output}; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 9 - 10 - pub const LXM: &str = "/sh.tangled.repo.languages"; 11 - 12 - #[tracing::instrument(target = "sh_tangled::repo::languages", skip(knot, repository), err)] 13 - pub async fn handle( 14 - State(knot): State<Knot>, 15 - XrpcQuery(params): XrpcQuery<Input>, 16 - repository: TangledRepository, 17 - ) -> XrpcResult<Json<Output>> { 18 - knot.pool() 19 - .spawn_async(move || repository.languages(params)) 20 - .await 21 - }
crates/knot/src/public/xrpc/sh_tangled/repo/impl_log.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_log.rs
-44
crates/knot/src/public/xrpc/sh_tangled/repo/impl_merge_check.rs
··· 1 - use axum::{Json, extract::State}; 2 - use lexicon::sh_tangled::repo::merge_check::{Input, Output}; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::{ 6 - model::{Knot, errors, repository::TangledRepository}, 7 - public::xrpc::XrpcResult, 8 - types::repository_path::RepositoryPath, 9 - }; 10 - 11 - pub const LXM: &str = "/sh.tangled.repo.mergeCheck"; 12 - 13 - #[tracing::instrument(target = "sh_tangled::repo::merge_check", skip(knot, patch), err)] 14 - pub async fn handle( 15 - State(knot): State<Knot>, 16 - Json(Input { 17 - did, 18 - name, 19 - patch, 20 - branch, 21 - }): Json<Input>, 22 - ) -> XrpcResult<Json<Output>> { 23 - let repo_path = RepositoryPath { 24 - owner: did.into_boxed().into(), 25 - name: name.into_boxed_str(), 26 - }; 27 - 28 - let repo_key = knot 29 - .resolve_repo_key(&repo_path) 30 - .await 31 - .map_err(errors::RepoNotFound)?; 32 - 33 - let repo = knot 34 - .open_repository(&repo_key) 35 - .await 36 - .map_err(errors::RepoNotFound)? 37 - .to_thread_local(); 38 - 39 - let repository: TangledRepository = (knot.clone(), repo_key, repo).into(); 40 - 41 - knot.pool() 42 - .spawn_async(move || repository.merge_check(patch, &branch)) 43 - .await 44 - }
-105
crates/knot/src/public/xrpc/sh_tangled/repo/impl_set_default_branch.rs
··· 1 - use atproto::Nsid; 2 - use axum::{Json, extract::State}; 3 - use gix::{ 4 - lock::acquire::Fail, 5 - refs::{ 6 - FullName, Target, 7 - transaction::{Change, LogChange, PreviousValue, RefEdit}, 8 - }, 9 - }; 10 - use lexicon::sh_tangled::repo::set_default_branch::Input; 11 - 12 - use crate::{ 13 - model::{Knot, errors}, 14 - nsid::SH_TANGLED_REPO_SETDEFAULTBRANCH, 15 - public::xrpc::XrpcError, 16 - services::{ 17 - authorization::{Authorization, Verification}, 18 - rbac::{Action, Policy, PolicyResult::Granted, RepositoryEditPolicy, RepositoryRef}, 19 - }, 20 - types::repository_key::RepositoryKey, 21 - }; 22 - 23 - pub const LXM: &str = "/sh.tangled.repo.setDefaultBranch"; 24 - 25 - #[derive(Debug)] 26 - pub struct SetDefaultBranchVerification; 27 - 28 - impl Verification for SetDefaultBranchVerification { 29 - const LEXICON_METHOD: &'static Nsid = SH_TANGLED_REPO_SETDEFAULTBRANCH; 30 - } 31 - 32 - #[tracing::instrument(target = "sh_tangled::repo::setDefaultBranch", skip(knot), err)] 33 - pub async fn handle( 34 - State(knot): State<Knot>, 35 - authorization: Authorization<SetDefaultBranchVerification>, 36 - Json(Input { 37 - repo, 38 - default_branch, 39 - }): Json<Input>, 40 - ) -> Result<(), XrpcError> { 41 - if repo.collection != "sh.tangled.repo" { 42 - return Err(errors::InvalidRequest( 43 - "Wrong collection in repo URI, expected 'sh.tangled.repo'", 44 - ))?; 45 - } 46 - 47 - let repo_key = RepositoryKey::new(repo.authority, repo.rkey).map_err(errors::InvalidRequest)?; 48 - 49 - let claims = authorization.claims(); 50 - let policy = RepositoryEditPolicy; 51 - let can_create = policy 52 - .evaluate_access( 53 - &claims.iss.as_ref(), 54 - &Action::RepositoryEdit, 55 - &RepositoryRef::from(&repo_key), 56 - &knot, 57 - ) 58 - .await; 59 - 60 - if !matches!(can_create, Granted) { 61 - return Err(errors::Forbidden(format!( 62 - "'{}' does not have permission to modify repositories on this knot", 63 - claims.iss 64 - )))?; 65 - } 66 - 67 - let repository = knot 68 - .open_repository(&repo_key) 69 - .await 70 - .map_err(errors::RepoNotFound)? 71 - .to_thread_local(); 72 - 73 - let target_name: FullName = format!("refs/heads/{default_branch}") 74 - .try_into() 75 - .map_err(errors::InvalidRequest)?; 76 - 77 - let ref_change = RefEdit { 78 - change: Change::Update { 79 - log: LogChange::default(), 80 - expected: PreviousValue::Any, 81 - new: Target::Symbolic(target_name), 82 - }, 83 - name: "HEAD".try_into().expect("HEAD is a valid reference"), 84 - deref: false, 85 - }; 86 - 87 - let ref_log = repository 88 - .refs 89 - .transaction() 90 - .prepare([ref_change], Fail::Immediately, Fail::Immediately) 91 - .map_err(errors::Internal)? 92 - .commit(None) 93 - .map_err(errors::Internal)?; 94 - 95 - let Some(change) = ref_log.first() else { 96 - return Err(errors::Internal("Not ref changes applied"))?; 97 - }; 98 - 99 - let from = change.change.previous_value(); 100 - let to = change.change.new_value(); 101 - 102 - tracing::info!(?from, ?to, "updated HEAD"); 103 - 104 - Ok(()) 105 - }
crates/knot/src/public/xrpc/sh_tangled/repo/impl_tags.rs crates/gordian-knot/src/public/xrpc/sh_tangled/repo/impl_tags.rs
-22
crates/knot/src/public/xrpc/sh_tangled/repo/impl_tree.rs
··· 1 - use axum::{Json, extract::State}; 2 - use lexicon::sh_tangled::repo::tree::{Input, Output}; 3 - use tokio_rayon::AsyncThreadPool as _; 4 - 5 - use crate::{ 6 - model::{Knot, repository::TangledRepository}, 7 - public::xrpc::{XrpcQuery, XrpcResult}, 8 - }; 9 - 10 - pub const LXM: &str = "/sh.tangled.repo.tree"; 11 - 12 - #[tracing::instrument(target = "sh_tangled::repo::tree", skip(knot, repository), err)] 13 - pub async fn handle( 14 - State(knot): State<Knot>, 15 - XrpcQuery(params): XrpcQuery<Input>, 16 - repository: TangledRepository, 17 - ) -> XrpcResult<Json<Output>> { 18 - let cloned_knot = knot.clone(); 19 - knot.pool() 20 - .spawn_async(move || repository.tree(params, cloned_knot.readmes())) 21 - .await 22 - }
crates/knot/src/services.rs crates/gordian-knot/src/services.rs
-118
crates/knot/src/services/atrepo.rs
··· 1 - use atproto::Did; 2 - use bytes::Bytes; 3 - use gix::bstr::ByteSlice as _; 4 - use identity::HttpClient; 5 - use lexicon::com::atproto::repo::list_records; 6 - 7 - #[derive(Debug, thiserror::Error)] 8 - pub enum Error<E> { 9 - #[error(transparent)] 10 - Reqwest(#[from] reqwest::Error), 11 - #[error(transparent)] 12 - Resolve(#[from] identity::ResolveError), 13 - #[error("DID document fails to declare PDS service endpoint")] 14 - MissingPDS, 15 - #[error(transparent)] 16 - InvalidAtUri(#[from] atproto::aturi::Error), 17 - #[error(transparent)] 18 - Serde(#[from] serde_json::Error), 19 - #[error("Error in callback")] 20 - Callback(E), 21 - } 22 - 23 - pub async fn fetch_collection<F, E>( 24 - resolver: &identity::Resolver, 25 - http: &reqwest::Client, 26 - did: &Did, 27 - collection: &str, 28 - mut callback: F, 29 - ) -> Result<(), Error<E>> 30 - where 31 - F: AsyncFnMut(&[list_records::Record]) -> Result<(), E> + Send + 'static, 32 - { 33 - use url::Url; 34 - 35 - fn list_records_url(mut pds: Url, collection: &str, repo: &Did, cursor: Option<&str>) -> Url { 36 - pds.set_path("/xrpc/com.atproto.repo.listRecords"); 37 - 38 - let mut query = pds.query_pairs_mut(); 39 - query.append_pair("repo", repo.as_str()); 40 - query.append_pair("collection", collection); 41 - if let Some(cursor) = cursor { 42 - query.append_pair("cursor", cursor); 43 - } 44 - drop(query); 45 - 46 - pds 47 - } 48 - 49 - let (_, doc) = resolver.resolve(did.as_str()).await?; 50 - let pds = &doc.atproto_pds().ok_or(Error::MissingPDS)?.service_endpoint; 51 - 52 - let mut complete = false; 53 - let mut cursor: Option<String> = None; 54 - while !complete { 55 - let response = http 56 - .get(list_records_url( 57 - pds.clone(), 58 - collection, 59 - did, 60 - cursor.as_deref(), 61 - )) 62 - .send() 63 - .await? 64 - .error_for_status()? 65 - .bytes() 66 - .await?; 67 - 68 - let parsed: list_records::Output = serde_json::from_slice(response.as_bytes())?; 69 - if let Some(new_cursor) = parsed.cursor { 70 - cursor.replace(new_cursor.to_owned()); 71 - } 72 - 73 - complete = parsed 74 - .records 75 - .last() 76 - .is_none_or(|last| Some(last.uri.rkey.as_str()) == cursor.as_deref()); 77 - 78 - callback(&parsed.records).await.map_err(Error::Callback)?; 79 - } 80 - 81 - Ok(()) 82 - } 83 - 84 - pub async fn fetch_record_bytes( 85 - resolver: &identity::Resolver, 86 - http: &HttpClient, 87 - did: &Did, 88 - collection: &str, 89 - rkey: &str, 90 - ) -> anyhow::Result<Bytes> { 91 - use url::Url; 92 - 93 - fn get_record_url(mut pds: Url, repo: &Did, collection: &str, rkey: &str) -> Url { 94 - pds.set_path("/xrpc/com.atproto.repo.getRecord"); 95 - let mut query = pds.query_pairs_mut(); 96 - query.append_pair("repo", repo.as_str()); 97 - query.append_pair("collection", collection); 98 - query.append_pair("rkey", rkey); 99 - drop(query); 100 - pds 101 - } 102 - 103 - let (_, doc) = resolver.resolve(did.as_str()).await?; 104 - let pds = &doc 105 - .atproto_pds() 106 - .ok_or(anyhow::anyhow!("DID document does not declare a pds"))? 107 - .service_endpoint; 108 - 109 - let response = http 110 - .get(get_record_url(pds.clone(), did, collection, rkey)) 111 - .send() 112 - .await? 113 - .error_for_status()? 114 - .bytes() 115 - .await?; 116 - 117 - Ok(response) 118 - }
-234
crates/knot/src/services/authorization.rs
··· 1 - use core::fmt; 2 - 3 - use atproto::Nsid; 4 - use auth::{ 5 - IntoVerificationKey as _, 6 - jwt::{Claims, Token, decode}, 7 - }; 8 - use axum::{ 9 - extract::{FromRef, FromRequestParts}, 10 - http::{ 11 - header::{AUTHORIZATION, AsHeaderName}, 12 - request::Parts, 13 - }, 14 - }; 15 - use futures_util::future::BoxFuture; 16 - use identity::Resolver; 17 - use time::OffsetDateTime; 18 - 19 - use crate::{ 20 - model::{Knot, errors}, 21 - public::xrpc::XrpcError, 22 - }; 23 - 24 - #[derive(Debug, thiserror::Error)] 25 - #[error("transparent")] 26 - pub struct AuthorizationClaimsStoreError(pub Box<dyn std::error::Error>); 27 - 28 - pub trait AuthorizationClaimsStore<T>: Send + Sync { 29 - fn get_unexpired_claims<'a: 'b, 'b>( 30 - &'a self, 31 - jti: &'b str, 32 - now: i64, 33 - ) -> BoxFuture<'b, Result<Option<T>, AuthorizationClaimsStoreError>>; 34 - 35 - fn store_claims( 36 - &self, 37 - claims: T, 38 - now: i64, 39 - ) -> BoxFuture<'_, Result<(), AuthorizationClaimsStoreError>>; 40 - } 41 - 42 - #[derive(Debug, thiserror::Error)] 43 - pub enum VerificationError { 44 - #[error("invalid lxm in authorization claims")] 45 - LexiconMethod, 46 - #[error("invalid iat in authorization claims")] 47 - UseBeforeIssue, 48 - #[error("invalid exp in authorization claims")] 49 - UseAfterExpiry, 50 - #[error("invalid aud in authorization claims")] 51 - WrongAudience, 52 - #[error("re-used authorization")] 53 - Reused, 54 - #[error("failed to read claims storage: {0}")] 55 - Storage(#[from] AuthorizationClaimsStoreError), 56 - } 57 - 58 - pub trait Verification: fmt::Debug + Send { 59 - const LEXICON_METHOD: &'static Nsid; 60 - 61 - fn verify_iat(now: i64, claims: &Claims) -> Result<i64, VerificationError> { 62 - match claims.iat { 63 - iat if iat <= now => Ok(iat), 64 - _ => Err(VerificationError::UseBeforeIssue), 65 - } 66 - } 67 - 68 - fn verify_exp(now: i64, claims: &Claims) -> Result<i64, VerificationError> { 69 - match claims.exp { 70 - exp if exp > now => Ok(exp), 71 - _ => Err(VerificationError::UseAfterExpiry), 72 - } 73 - } 74 - 75 - /// Verify [`Claims::lxm`] matches the required value. 76 - fn verify_lexicon_method(claims: &Claims) -> Result<&'static str, VerificationError> { 77 - match claims.lxm.as_deref() { 78 - Some(lxm) if lxm == Self::LEXICON_METHOD => Ok(Self::LEXICON_METHOD), 79 - _ => Err(VerificationError::LexiconMethod), 80 - } 81 - } 82 - 83 - fn verify_audience<'a>( 84 - audience: &atproto::Did, 85 - claims: &'a Claims, 86 - ) -> Result<&'a atproto::Did, VerificationError> { 87 - match claims.aud == audience { 88 - true => Ok(&claims.aud), 89 - false => Err(VerificationError::WrongAudience), 90 - } 91 - } 92 - 93 - fn verify_unique( 94 - store: &dyn AuthorizationClaimsStore<Claims>, 95 - now: i64, 96 - claims: &Claims, 97 - ) -> impl Future<Output = Result<(), VerificationError>> + Send { 98 - async move { 99 - match store.get_unexpired_claims(&claims.jti, now).await? { 100 - Some(stored_claims) if stored_claims.exp < now => Ok(()), 101 - None => Ok(()), 102 - _ => Err(VerificationError::Reused), 103 - } 104 - } 105 - } 106 - 107 - fn verify( 108 - store: &dyn AuthorizationClaimsStore<Claims>, 109 - now: i64, 110 - audience: &atproto::Did, 111 - claims: &Claims, 112 - ) -> impl Future<Output = Result<(), VerificationError>> + Send { 113 - async move { 114 - Self::verify_iat(now, claims)?; 115 - Self::verify_exp(now, claims)?; 116 - Self::verify_lexicon_method(claims)?; 117 - Self::verify_audience(audience, claims)?; 118 - Self::verify_unique(store, now, claims).await?; 119 - Ok(()) 120 - } 121 - } 122 - } 123 - 124 - /// Extracts and verifies the inter-service authorization from a request. 125 - #[derive(Clone, Debug)] 126 - pub struct Authorization<V: Verification> { 127 - claims: Claims, 128 - _phantom: std::marker::PhantomData<V>, 129 - } 130 - 131 - impl<V: Verification> Authorization<V> { 132 - fn new(claims: Claims) -> Self { 133 - Self { 134 - claims, 135 - _phantom: std::marker::PhantomData, 136 - } 137 - } 138 - 139 - /// Extract the verified authorization claims. 140 - #[inline] 141 - pub fn claims(self) -> Claims { 142 - self.claims 143 - } 144 - } 145 - 146 - impl<S: Sync, V: Verification> FromRequestParts<S> for Authorization<V> 147 - where 148 - Knot: FromRef<S>, 149 - Resolver: FromRef<S>, 150 - { 151 - type Rejection = XrpcError; 152 - 153 - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 154 - let knot = Knot::from_ref(state); 155 - let resolver = Resolver::from_ref(state); 156 - let now = OffsetDateTime::now_utc().unix_timestamp(); 157 - 158 - let credential = extract_token(parts, AUTHORIZATION, "bearer") 159 - .ok_or(errors::Unauthorized("inter-service authorization required"))?; 160 - 161 - let unverified_token = Token::decode_unverified(credential) 162 - .map_err(|_| errors::Unauthorized("inter-service authorization required"))?; 163 - 164 - // Before performing a relatively expensive DID look-up, ensure the token 165 - // claims are valid. 166 - let unverified_claims = unverified_token.claims; 167 - V::verify(&knot, now, knot.instance(), &unverified_claims) 168 - .await 169 - .map_err(errors::Forbidden)?; 170 - 171 - // Resolve the DID document for the claimed issuer, extract and parse 172 - // the verification methods into public keys. 173 - 174 - let (resolved_did, doc) = resolver 175 - .resolve(unverified_claims.iss.as_str()) 176 - .await 177 - .map_err(errors::Forbidden)?; 178 - 179 - assert_eq!(unverified_claims.iss, resolved_did); 180 - 181 - // @QUESTION Should we check all verification keys, or just the first? 182 - let verification_keys = doc 183 - .verification_method 184 - .into_iter() 185 - .filter_map(|vm| vm.into_verification_key().ok()); 186 - 187 - // @TODO (Maybe) allow the Verifier to restrict acceptable signature algorithms. 188 - 189 - // Try to decode and verify the JWT using any one of the public keys 190 - // we have for the DID. 191 - for verification_key in verification_keys { 192 - if let Ok(token) = decode::<Claims>(credential, &verification_key) { 193 - let claims = token.claims; 194 - 195 - // Store the JWT so it cannot be re-used. 196 - knot.store_claims(claims.clone(), now) 197 - .await 198 - .map_err(errors::Internal)?; 199 - 200 - return Ok(Self::new(claims)); 201 - } 202 - } 203 - 204 - Err(errors::Forbidden( 205 - "failed to verifiy inter-service authorization", 206 - ))? 207 - } 208 - } 209 - 210 - pub fn extract_token<'a>( 211 - parts: &'a Parts, 212 - header_name: impl AsHeaderName, 213 - match_scheme: &str, 214 - ) -> Option<&'a str> { 215 - for header_value in parts.headers.get_all(header_name) { 216 - let Ok(s) = std::str::from_utf8(header_value.as_bytes()) else { 217 - continue; 218 - }; 219 - 220 - let mut header_parts = s.split_ascii_whitespace(); 221 - match ( 222 - header_parts.next(), 223 - header_parts.next(), 224 - header_parts.next(), 225 - ) { 226 - (Some(scheme), Some(credential), None) if scheme.to_lowercase() == match_scheme => { 227 - return Some(credential); 228 - } 229 - _ => continue, 230 - } 231 - } 232 - 233 - None 234 - }
-554
crates/knot/src/services/database.rs
··· 1 - // mod pg_impl; 2 - pub mod types; 3 - 4 - use atproto::{Did, did::OwnedDid}; 5 - use futures_util::{StreamExt, stream::BoxStream}; 6 - use jetstream::Value; 7 - use lexicon::sh_tangled::{PublicKey, knot::Member, repo::Repo}; 8 - use serde::Serialize; 9 - use sqlx::{SqlitePool, error::ErrorKind}; 10 - use time::OffsetDateTime; 11 - use types::{DeletedRecord, EventRow}; 12 - 13 - use crate::types::RecordKey; 14 - 15 - #[derive(Debug, thiserror::Error)] 16 - pub enum DataStoreError { 17 - #[error("Database error: {0}")] 18 - Sqlx(#[from] sqlx::Error), 19 - #[error("Failed to parse DID from db: {0}")] 20 - Did(#[from] atproto::did::Error), 21 - #[error("Failed to extract AT-URI: {0}")] 22 - AtUri(#[from] atproto::aturi::Error), 23 - #[error(transparent)] 24 - DateTime(#[from] time::error::ComponentRange), 25 - #[error("Invalid JSON data in database: {0}")] 26 - Json(#[from] serde_json::Error), 27 - #[error("{0}")] 28 - Other(#[from] anyhow::Error), 29 - } 30 - 31 - #[derive(Clone, Debug)] 32 - pub struct DataStore { 33 - db: SqlitePool, 34 - } 35 - 36 - impl DataStore { 37 - pub fn new(db: SqlitePool) -> Self { 38 - Self { db } 39 - } 40 - 41 - pub async fn get_jetstream_cursor(&self) -> Result<Option<OffsetDateTime>, DataStoreError> { 42 - let result = sqlx::query!("SELECT cursor FROM jetstream_cursor WHERE id = 1") 43 - .fetch_optional(&self.db) 44 - .await?; 45 - 46 - let cursor = result 47 - .map(|record| { 48 - OffsetDateTime::from_unix_timestamp_nanos(i128::from(record.cursor) * 1000) 49 - }) 50 - .transpose()?; 51 - 52 - Ok(cursor) 53 - } 54 - 55 - pub async fn store_jetstream_cursor( 56 - &self, 57 - cursor: OffsetDateTime, 58 - ) -> Result<(), DataStoreError> { 59 - let cursor = i64::try_from(cursor.unix_timestamp_nanos() / 1000).unwrap(); 60 - sqlx::query!("INSERT INTO jetstream_cursor (id, cursor) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor", cursor).execute(&self.db).await?; 61 - Ok(()) 62 - } 63 - 64 - pub fn knot_members(&self) -> BoxStream<'_, Result<OwnedDid, DataStoreError>> { 65 - sqlx::query!(r#"SELECT subject AS "subject: OwnedDid" FROM knot_member ORDER BY rkey, rev"#) 66 - .fetch(&self.db) 67 - .map(|record| Ok(record?.subject)) 68 - .boxed() 69 - } 70 - 71 - /// Get all the knot members and repository collaborators associated with the knot. 72 - pub fn members(&self) -> BoxStream<'_, Result<OwnedDid, DataStoreError>> { 73 - sqlx::query!(r#"SELECT DISTINCT subject AS "subject: OwnedDid" FROM knot_member UNION SELECT DISTINCT subject AS "subject: OwnedDid" FROM repository_collaborator"#) 74 - .fetch(&self.db) 75 - .map(|record| Ok(record?.subject)) 76 - .boxed() 77 - } 78 - 79 - /// Upsert a knot member. 80 - /// 81 - /// Returns `true` if the member record was newly inserted/updated, or `false` if 82 - /// the member was already present in the database. 83 - /// 84 - pub async fn upsert_knot_member( 85 - &self, 86 - rkey: &str, 87 - rev: &str, 88 - cid: &str, 89 - member: &Member<'_>, 90 - ) -> Result<bool, DataStoreError> { 91 - let subject = member.subject.as_ref(); 92 - let result = sqlx::query!( 93 - "INSERT INTO knot_member 94 - (rkey, rev, cid, subject, domain, created_at) 95 - VALUES (?, ?, ?, ?, ?, ?) 96 - ON CONFLICT (rkey) 97 - DO UPDATE 98 - SET 99 - rev = excluded.rev, 100 - cid = excluded.cid, 101 - subject = excluded.subject, 102 - domain = excluded.domain, 103 - created_at = excluded.created_at 104 - WHERE excluded.rev > knot_member.rev 105 - RETURNING rkey", 106 - rkey, 107 - rev, 108 - cid, 109 - subject, 110 - member.domain, 111 - member.created_at 112 - ) 113 - .fetch_optional(&self.db) 114 - .await?; 115 - 116 - Ok(result.is_some()) 117 - } 118 - 119 - pub async fn delete_knot_member( 120 - &self, 121 - rkey: &str, 122 - rev: &str, 123 - ) -> Result<Option<OwnedDid>, DataStoreError> { 124 - let result = sqlx::query!( 125 - r#"DELETE FROM knot_member WHERE rkey = ? AND rev <= ? RETURNING subject AS "subject: OwnedDid""#, 126 - rkey, 127 - rev 128 - ) 129 - .fetch_optional(&self.db) 130 - .await?; 131 - 132 - Ok(result.map(|record| record.subject)) 133 - } 134 - 135 - /// Upsert a repository collaborator. 136 - /// 137 - /// Returns `true` if the collaborator record was newly inserted/updated, or `false` if 138 - /// the collaborator was already present in the database. 139 - /// 140 - pub async fn upsert_repository_collaborator( 141 - &self, 142 - did: &Did, 143 - rkey: &str, 144 - rev: &str, 145 - cid: &str, 146 - repo_did: &Did, 147 - repo_rkey: &str, 148 - subject: &Did, 149 - created_at: OffsetDateTime, 150 - ) -> Result<bool, DataStoreError> { 151 - let result = sqlx::query!( 152 - "INSERT INTO repository_collaborator 153 - (did, rkey, rev, cid, repo_did, repo_rkey, subject, created_at) 154 - VALUES (?, ?, ?, ?, ?, ?, ?, ?) 155 - ON CONFLICT (did, rkey) 156 - DO UPDATE 157 - SET 158 - rev = excluded.rev, 159 - cid = excluded.cid, 160 - repo_did = excluded.repo_did, 161 - repo_rkey = excluded.repo_rkey, 162 - subject = excluded.subject, 163 - created_at = excluded.created_at 164 - WHERE excluded.rev > repository_collaborator.rev 165 - RETURNING rkey", 166 - did, 167 - rkey, 168 - rev, 169 - cid, 170 - repo_did, 171 - repo_rkey, 172 - subject, 173 - created_at 174 - ) 175 - .fetch_optional(&self.db) 176 - .await?; 177 - 178 - Ok(result.is_some()) 179 - } 180 - 181 - pub async fn get_repository_collaborator_record( 182 - &self, 183 - did: &Did, 184 - rkey: &str, 185 - ) -> Result<(OwnedDid, String, OwnedDid), DataStoreError> { 186 - let result = sqlx::query!( 187 - r#"SELECT repo_did as "repo_did: OwnedDid", repo_rkey, subject as "subject: OwnedDid" FROM repository_collaborator WHERE did = ? AND rkey = ?"#, 188 - did, 189 - rkey 190 - ) 191 - .fetch_one(&self.db) 192 - .await?; 193 - 194 - Ok((result.repo_did, result.repo_rkey, result.subject)) 195 - } 196 - 197 - pub async fn delete_repository_collaborator( 198 - &self, 199 - did: &Did, 200 - rkey: &str, 201 - rev: &str, 202 - ) -> Result<Option<OwnedDid>, DataStoreError> { 203 - let result = sqlx::query!( 204 - r#"DELETE FROM repository_collaborator WHERE did = ? AND rkey = ? AND rev <= ? RETURNING subject AS "subject: OwnedDid""#, 205 - did, 206 - rkey, 207 - rev 208 - ) 209 - .fetch_optional(&self.db) 210 - .await?; 211 - 212 - Ok(result.map(|record| record.subject)) 213 - } 214 - 215 - /// Get the OpenSSH public keys for the specified DID. 216 - pub async fn public_keys_for_did( 217 - &self, 218 - did: &Did, 219 - ) -> Result<Vec<PublicKey<'static>>, DataStoreError> { 220 - let keys = sqlx::query_as!( 221 - PublicKey, 222 - "SELECT name, key, created_at FROM public_key WHERE did = ? ORDER BY rkey, rev", 223 - did 224 - ) 225 - .fetch_all(&self.db) 226 - .await?; 227 - 228 - Ok(keys) 229 - } 230 - 231 - pub async fn upsert_public_key( 232 - &self, 233 - did: &Did, 234 - rkey: &str, 235 - rev: &str, 236 - cid: &str, 237 - public_key: &PublicKey<'_>, 238 - ) -> Result<(), DataStoreError> { 239 - sqlx::query!( 240 - "INSERT INTO public_key 241 - (did, rkey, rev, cid, name, key, created_at) 242 - VALUES (?, ?, ?, ?, ?, ?, ?) 243 - ON CONFLICT (did, rkey) 244 - DO UPDATE 245 - SET 246 - rev = excluded.rev, 247 - cid = excluded.cid, 248 - name = excluded.name, 249 - key = excluded.key, 250 - created_at = excluded.created_at 251 - WHERE excluded.rev > public_key.rev", 252 - did, 253 - rkey, 254 - rev, 255 - cid, 256 - public_key.name, 257 - public_key.key, 258 - public_key.created_at 259 - ) 260 - .execute(&self.db) 261 - .await?; 262 - 263 - Ok(()) 264 - } 265 - 266 - pub async fn delete_public_key( 267 - &self, 268 - did: &Did, 269 - rkey: &str, 270 - rev: &str, 271 - ) -> Result<Option<DeletedRecord>, DataStoreError> { 272 - let record = sqlx::query!( 273 - r#"DELETE FROM public_key WHERE did = ? AND rkey = ? AND rev <= ? RETURNING did AS "did: OwnedDid", rkey, rev, cid"#, 274 - did, 275 - rkey, 276 - rev 277 - ) 278 - .fetch_optional(&self.db) 279 - .await?; 280 - 281 - Ok(match record { 282 - Some(record) => Some(DeletedRecord { 283 - did: record.did, 284 - rkey: record.rkey, 285 - rev: record.rev, 286 - cid: record.cid, 287 - }), 288 - None => None, 289 - }) 290 - } 291 - 292 - pub async fn update_repository( 293 - &self, 294 - did: &Did, 295 - rkey: &str, 296 - rev: &str, 297 - cid: &str, 298 - repository: &Repo<'_>, 299 - ) -> Result<(), DataStoreError> { 300 - sqlx::query!( 301 - "UPDATE repository \ 302 - SET rev = ?,\ 303 - cid = ?,\ 304 - name = ?,\ 305 - knot = ?,\ 306 - spindle = ?,\ 307 - source = ?,\ 308 - created_at = ?\ 309 - WHERE 310 - did = ? 311 - AND rkey = ? 312 - AND rev <= ?", 313 - rev, 314 - cid, 315 - repository.name, 316 - repository.knot, 317 - repository.spindle, 318 - repository.source, 319 - repository.created_at, 320 - did, 321 - rkey, 322 - rev 323 - ) 324 - .execute(&self.db) 325 - .await?; 326 - 327 - Ok(()) 328 - } 329 - 330 - pub async fn resolve_repository( 331 - &self, 332 - did: &Did, 333 - name_or_rkey: &str, 334 - ) -> Result<Option<(String, String)>, DataStoreError> { 335 - let resolved = sqlx::query!( 336 - "SELECT rkey, name FROM repository WHERE did = ? AND (name = ? OR rkey = ?)", 337 - did, 338 - name_or_rkey, 339 - name_or_rkey 340 - ) 341 - .fetch_optional(&self.db) 342 - .await?; 343 - 344 - tracing::debug!(?resolved); 345 - 346 - Ok(resolved.map(|record| (record.rkey, record.name))) 347 - } 348 - 349 - pub async fn is_repository_member( 350 - &self, 351 - repo_did: &Did, 352 - repo_rkey: &str, 353 - subject: &Did, 354 - ) -> Result<bool, DataStoreError> { 355 - let member = sqlx::query!( 356 - r#"SELECT repo_did as "repo_did: OwnedDid", repo_rkey, subject as "subject: OwnedDid" FROM repository_collaborator WHERE repo_did = ? AND repo_rkey = ? AND subject = ?"#, 357 - repo_did, 358 - repo_rkey, 359 - subject 360 - ).fetch_optional(&self.db).await?; 361 - 362 - Ok(member.is_some_and(|record| { 363 - record.repo_did == repo_did 364 - && record.repo_rkey == repo_rkey 365 - && record.subject == subject 366 - })) 367 - } 368 - 369 - pub async fn is_knot_member(&self, did: &Did) -> Result<bool, DataStoreError> { 370 - let member = sqlx::query!( 371 - r#"SELECT subject as "subject: OwnedDid" FROM knot_member WHERE subject = ? LIMIT 1"#, 372 - did, 373 - ) 374 - .fetch_optional(&self.db) 375 - .await?; 376 - 377 - Ok(member 378 - .map(|record| record.subject == did) 379 - .unwrap_or_default()) 380 - } 381 - 382 - pub async fn insert_event<T>( 383 - &self, 384 - ts: OffsetDateTime, 385 - repo_did: &Did, 386 - repo_rkey: &str, 387 - collection: &str, 388 - event: &T, 389 - ) -> Result<i64, DataStoreError> 390 - where 391 - T: Serialize, 392 - { 393 - let record = serde_json::to_value(event).unwrap(); 394 - let result = sqlx::query!( 395 - "INSERT INTO event (ts, repo_did, repo_rkey, collection, record) VALUES (?, ?, ?, ?, ?) RETURNING id", 396 - ts, 397 - repo_did, 398 - repo_rkey, 399 - collection, 400 - record 401 - ).fetch_one(&self.db).await?; 402 - 403 - Ok(result.id) 404 - } 405 - 406 - pub fn get_events<'a: 'b, 'b>( 407 - &'a self, 408 - from: &'b OffsetDateTime, 409 - ) -> BoxStream<'b, Result<EventRow, DataStoreError>> { 410 - sqlx::query_as!( 411 - EventRow, 412 - r#"SELECT id, ts, collection, record as "record: Value" FROM event WHERE ts >= ? ORDER BY id"#, 413 - *from 414 - ) 415 - .fetch(&self.db) 416 - .map(|record| { 417 - let record = record?; 418 - Ok(record) 419 - }) 420 - .boxed() 421 - } 422 - 423 - pub async fn store_claims( 424 - &self, 425 - claims: auth::jwt::Claims, 426 - now: i64, 427 - ) -> Result<(), DataStoreError> { 428 - let mut transaction = self.db.begin().await?; 429 - 430 - // First delete any expired claims. 431 - sqlx::query!( 432 - "DELETE FROM claim WHERE json_extract(claims, '$.exp') < ?", 433 - now 434 - ) 435 - .execute(&mut *transaction) 436 - .await?; 437 - 438 - let id = &claims.jti; 439 - let claims = serde_json::to_value(&claims)?; 440 - sqlx::query!("INSERT INTO claim (id, claims) VALUES (?, ?)", id, claims) 441 - .execute(&mut *transaction) 442 - .await?; 443 - 444 - transaction.commit().await?; 445 - Ok(()) 446 - } 447 - 448 - pub async fn get_claims( 449 - &self, 450 - id: &str, 451 - now: i64, 452 - ) -> Result<Option<auth::jwt::Claims>, DataStoreError> { 453 - let claims = sqlx::query!( 454 - r#"SELECT claims as "claims: Value" FROM claim WHERE id = ? AND json_extract(claims, '$.exp') >= ?"#, 455 - id, now 456 - ) 457 - .fetch_optional(&self.db) 458 - .await? 459 - .map(|record| serde_json::from_value::<auth::jwt::Claims>(record.claims)) 460 - .transpose()?; 461 - 462 - Ok(claims) 463 - } 464 - 465 - pub async fn delete_claims(&self, id: &str) -> Result<(), DataStoreError> { 466 - sqlx::query!("DELETE FROM claim WHERE id = ?", id) 467 - .execute(&self.db) 468 - .await?; 469 - Ok(()) 470 - } 471 - 472 - pub async fn begin<'db>(&'db self) -> Result<DatabaseTransaction<'db>, DataStoreError> { 473 - let tx = self.db.begin().await?; 474 - Ok(DatabaseTransaction { tx }) 475 - } 476 - } 477 - 478 - pub struct DatabaseTransaction<'db> { 479 - tx: sqlx::SqliteTransaction<'db>, 480 - } 481 - 482 - impl<'db> DatabaseTransaction<'db> { 483 - pub async fn commit(self) -> Result<(), DataStoreError> { 484 - Ok(self.tx.commit().await?) 485 - } 486 - 487 - /// Insert a new repository entry from a jetstream commit, returning `true` if the repository 488 - /// appears to be new. 489 - /// 490 - /// # Note 491 - /// 492 - /// This is *not* an UPSERT. 493 - /// 494 - pub async fn insert_repository( 495 - &mut self, 496 - did: &Did, 497 - rkey: &str, 498 - rev: &str, 499 - cid: &str, 500 - repository: &Repo<'_>, 501 - ) -> Result<bool, DataStoreError> { 502 - let result = sqlx::query!( 503 - "INSERT INTO repository (did, rkey, rev, cid, name, knot, spindle, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 504 - did, 505 - rkey, 506 - rev, 507 - cid, 508 - repository.name, 509 - repository.knot, 510 - repository.spindle, 511 - repository.source, 512 - repository.created_at 513 - ).fetch_optional(&mut *self.tx).await; 514 - 515 - match result { 516 - Ok(_) => Ok(true), 517 - Err(error) => match error.as_database_error() { 518 - Some(database_error) if database_error.kind() == ErrorKind::UniqueViolation => { 519 - Ok(false) 520 - } 521 - _ => Err(error)?, 522 - }, 523 - } 524 - } 525 - 526 - pub async fn delete_repository( 527 - &mut self, 528 - rec: &RecordKey<'_>, 529 - ) -> Result<Option<(DeletedRecord, String)>, DataStoreError> { 530 - assert_eq!(rec.collection, "sh.tangled.repo"); 531 - 532 - let record = sqlx::query!( 533 - r#"DELETE FROM repository WHERE did = ? AND rkey = ? AND rev <= ? RETURNING did AS "did: OwnedDid", rkey, rev, cid, name"#, 534 - rec.did, 535 - rec.rkey, 536 - rec.rev 537 - ) 538 - .fetch_optional(&mut *self.tx) 539 - .await?; 540 - 541 - Ok(match record { 542 - Some(record) => Some(( 543 - DeletedRecord { 544 - did: record.did, 545 - rkey: record.rkey, 546 - rev: record.rev, 547 - cid: record.cid, 548 - }, 549 - record.name, 550 - )), 551 - None => None, 552 - }) 553 - } 554 - }
-84
crates/knot/src/services/database/types.rs
··· 1 - use atproto::{Did, did::OwnedDid}; 2 - use lexicon::sh_tangled::PublicKey; 3 - use time::OffsetDateTime; 4 - 5 - /// A flattened public key record. 6 - pub struct PublicKeyRecordRef<'a> { 7 - pub did: &'a Did, 8 - pub rkey: &'a str, 9 - pub cid: &'a str, 10 - pub name: &'a str, 11 - pub key: &'a str, 12 - pub created_at: &'a OffsetDateTime, 13 - } 14 - 15 - impl<'a> From<(&'a jetstream::Commit<'a>, &'a PublicKey<'a>)> for PublicKeyRecordRef<'a> { 16 - fn from((commit, key): (&'a jetstream::Commit<'a>, &'a PublicKey<'a>)) -> Self { 17 - Self { 18 - did: commit.did, 19 - rkey: commit.rkey, 20 - cid: commit.cid, 21 - name: &key.name, 22 - key: &key.key, 23 - created_at: &key.created_at, 24 - } 25 - } 26 - } 27 - 28 - /// An owned, flattened public key record. 29 - pub struct PublicKeyRecord { 30 - pub did: String, 31 - pub rkey: String, 32 - pub cid: String, 33 - pub name: String, 34 - pub key: String, 35 - pub created_at: OffsetDateTime, 36 - } 37 - 38 - impl<'a> From<(&'a jetstream::Commit<'a>, PublicKey<'a>)> for PublicKeyRecord { 39 - fn from((commit, key): (&'a jetstream::Commit<'a>, PublicKey<'a>)) -> Self { 40 - Self { 41 - did: commit.did.as_str().into(), 42 - rkey: commit.rkey.into(), 43 - cid: commit.cid.into(), 44 - name: key.name.into(), 45 - key: key.key.into(), 46 - created_at: key.created_at, 47 - } 48 - } 49 - } 50 - 51 - impl From<PublicKeyRecord> for PublicKey<'static> { 52 - fn from(value: PublicKeyRecord) -> Self { 53 - Self { 54 - key: value.key.into(), 55 - name: value.name.into(), 56 - created_at: value.created_at, 57 - } 58 - } 59 - } 60 - 61 - #[derive(Debug, sqlx::FromRow)] 62 - pub struct InsertRepositoryResult { 63 - pub name: String, 64 - pub old_xrpc_create_at: Option<OffsetDateTime>, 65 - pub new_xrpc_create_at: Option<OffsetDateTime>, 66 - pub old_jetstream_at: Option<OffsetDateTime>, 67 - pub new_jetstream_at: Option<OffsetDateTime>, 68 - } 69 - 70 - #[derive(Debug)] 71 - pub struct EventRow { 72 - pub id: i64, 73 - pub ts: OffsetDateTime, 74 - pub collection: String, 75 - pub record: serde_json::Value, 76 - } 77 - 78 - #[derive(Debug)] 79 - pub struct DeletedRecord { 80 - pub did: OwnedDid, 81 - pub rkey: String, 82 - pub rev: String, 83 - pub cid: String, 84 - }
-424
crates/knot/src/services/jetstream.rs
··· 1 - use crate::{ 2 - model::{Knot, KnotState}, 3 - services::rbac::{ 4 - Action, AddCollaboratorPolicy, AddMemberPolicy, Policy, PolicyResult::*, 5 - RemoveCollaboratorPolicy, RemoveMemberPolicy, RepositoryCreatePolicy, 6 - RepositoryDeletePolicy, RepositoryRef, 7 - }, 8 - }; 9 - use futures_util::StreamExt as _; 10 - use jetstream::{CommitEvent, Event, JetstreamClient, client_config::JetstreamConfig}; 11 - use lexicon::Lexicon; 12 - use std::{borrow::Cow, time::Duration}; 13 - use tokio::time::Instant; 14 - use tokio_util::sync::CancellationToken; 15 - 16 - pub fn init_consumer<T: AsRef<str>>( 17 - knot: &Knot, 18 - instances: &[T], 19 - shutdown: CancellationToken, 20 - ) -> impl Future<Output = anyhow::Result<()>> + use<T> { 21 - let knot = knot.clone(); 22 - let jetstream_instances: Vec<_> = instances 23 - .iter() 24 - .filter(|s| !s.as_ref().is_empty()) 25 - .map(|url| Cow::Owned(url.as_ref().to_string())) 26 - .collect(); 27 - 28 - async move { 29 - if jetstream_instances.is_empty() { 30 - tracing::warn!("no jetstream instances provided"); 31 - return Ok(()); 32 - } 33 - 34 - let cursor = knot 35 - .database() 36 - .get_jetstream_cursor() 37 - .await? 38 - .map(|odt| (odt, (odt.unix_timestamp_nanos() / 1000).unsigned_abs())); 39 - 40 - if let Some((cursor, cursor_us)) = &cursor { 41 - tracing::info!(?cursor, ?cursor_us, "found jetstream cursor"); 42 - } 43 - 44 - let mut config = JetstreamConfig::default() 45 - .with_instances(jetstream_instances) 46 - .with_cursor(cursor.map(|(_, us)| us)) 47 - .with_collections([ 48 - "sh.tangled.knot.member", 49 - "sh.tangled.publicKey", 50 - "sh.tangled.repo", 51 - "sh.tangled.repo.collaborator", 52 - ]); 53 - 54 - { 55 - let mut member_dids = knot.database().members(); 56 - while let Some(did) = member_dids.next().await { 57 - config 58 - .subscriber_options 59 - .add_did(did?) 60 - .expect("knot members shouldn't exceed maximum DID filters"); 61 - } 62 - } 63 - 64 - let (jetstream, jetstream_rx, jetstream_rx_task) = config.connect(); 65 - let (consumer_result, jetstream_result) = tokio::join!( 66 - tokio::spawn(consume(jetstream, knot, jetstream_rx, shutdown)), 67 - tokio::spawn(jetstream_rx_task) 68 - ); 69 - 70 - consumer_result?; 71 - jetstream_result?; 72 - 73 - Ok(()) 74 - } 75 - } 76 - 77 - pub async fn consume( 78 - client: JetstreamClient, 79 - knot: Knot, 80 - jetstream_rx: jetstream::JetstreamReceiver, 81 - shutdown: CancellationToken, 82 - ) { 83 - let mut last_jetstream_sync: Option<Instant> = None; 84 - 85 - while let Some(Some(raw_event)) = shutdown 86 - .run_until_cancelled(jetstream_rx.recv_async()) 87 - .await 88 - { 89 - let event = match raw_event.deserialize() { 90 - Ok(event) => event, 91 - Err(error) => { 92 - let msg = String::from_utf8_lossy(raw_event.as_bytes()); 93 - tracing::error!(?error, %msg, "failed to deserialize event"); 94 - continue; 95 - } 96 - }; 97 - 98 - tracing::debug!(?event); 99 - 100 - match &event { 101 - Event::Commit(commit) => match commit.collection() { 102 - "sh.tangled.knot.member" => { 103 - if let Err(error) = process_knot_member(&client, &knot, commit).await { 104 - tracing::error!(?error, "failed to process 'sh.tangled.knot_member' record") 105 - } 106 - } 107 - "sh.tangled.publicKey" => { 108 - if let Err(error) = process_public_key(&client, &knot, commit).await { 109 - tracing::error!(?error, "failed to process 'sh.tangled.publicKey' record") 110 - } 111 - } 112 - "sh.tangled.repo" => { 113 - if let Err(error) = process_repo(&client, &knot, commit).await { 114 - tracing::error!(?error, "failed to process 'sh.tangled.repo' record") 115 - } 116 - } 117 - "sh.tangled.repo.collaborator" => { 118 - if let Err(error) = process_repo_collaborator(&client, &knot, commit).await { 119 - tracing::error!( 120 - ?error, 121 - "failed to process 'sh.tangled.repo.collaborator' record" 122 - ) 123 - } 124 - } 125 - collection => unimplemented!("no handler implemented for '{collection}' commits"), 126 - }, 127 - Event::Account(account) => { 128 - knot.resolver().invalidate_did(account.did).await; 129 - } 130 - Event::Identity(identity) => { 131 - knot.resolver().invalidate_did(identity.did).await; 132 - } 133 - } 134 - 135 - if last_jetstream_sync.is_none_or(|value| value.elapsed() > Duration::from_secs(1)) { 136 - match knot.database().store_jetstream_cursor(event.ts()).await { 137 - Ok(()) => last_jetstream_sync = Some(Instant::now()), 138 - Err(error) => tracing::error!(?error, "failed to log jetstream event"), 139 - }; 140 - } 141 - } 142 - 143 - tracing::warn!("jetstream consumer finished"); 144 - } 145 - 146 - #[tracing::instrument(skip(knot))] 147 - async fn process_public_key<'db, 'c, 'k>( 148 - _: &JetstreamClient, 149 - knot: &KnotState, 150 - event: &'c CommitEvent<'c>, 151 - ) -> anyhow::Result<()> 152 - where 153 - 'c: 'k, 154 - 'db: 'c, 155 - { 156 - match event { 157 - CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 158 - let Lexicon::PublicKey(key) = serde_json::from_str(commit.record.get())? else { 159 - return Err(anyhow::anyhow!("expected a 'sh.tangled.publicKey' record")); 160 - }; 161 - 162 - if !knot.database().is_knot_member(event.did()).await? { 163 - return Ok(()); 164 - } 165 - 166 - knot.database() 167 - .upsert_public_key(commit.did, commit.rkey, commit.rev, commit.cid, &key) 168 - .await?; 169 - } 170 - CommitEvent::Delete(delete) => { 171 - assert_eq!(delete.collection, "sh.tangled.publicKey"); 172 - knot.database() 173 - .delete_public_key(delete.did, delete.rkey, delete.rev) 174 - .await?; 175 - } 176 - } 177 - 178 - tracing::info!("ingested 'sh.tangled.publicKey' from jetstream"); 179 - Ok(()) 180 - } 181 - 182 - #[tracing::instrument(skip(knot))] 183 - async fn process_repo( 184 - _: &JetstreamClient, 185 - knot: &KnotState, 186 - event: &CommitEvent<'_>, 187 - ) -> anyhow::Result<()> { 188 - match event { 189 - CommitEvent::Create(commit) => { 190 - let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 191 - return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 192 - }; 193 - 194 - if repository.knot != knot.instance_ident() { 195 - tracing::debug!( 196 - did = %event.did(), 197 - rkey = %event.rkey(), 198 - name = %repository.name, 199 - "repository is not for this knot, ignoring" 200 - ); 201 - return Ok(()); 202 - } 203 - 204 - let policy = RepositoryCreatePolicy; 205 - let can_create = policy 206 - .evaluate_access(&commit.did, &Action::RepositoryCreate, knot, knot) 207 - .await; 208 - 209 - if !matches!(can_create, Granted) { 210 - tracing::warn!(?commit, "RepositoryCreate permission denied"); 211 - return Ok(()); 212 - } 213 - 214 - knot.create_repo(&commit.try_into()?, &repository) 215 - .await 216 - .inspect_err(|error| tracing::error!(?error, "failed to create repository"))?; 217 - } 218 - CommitEvent::Update(commit) => { 219 - let Lexicon::Repo(repository) = serde_json::from_str(commit.record.get())? else { 220 - return Err(anyhow::anyhow!("expected a 'sh.tangled.repo' record")); 221 - }; 222 - 223 - if repository.knot != knot.instance_ident() { 224 - tracing::debug!( 225 - did = %event.did(), 226 - rkey = %event.rkey(), 227 - name = %repository.name, 228 - "repository is not for this knot, ignoring" 229 - ); 230 - return Ok(()); 231 - } 232 - 233 - // @TODO Does this need auth? 234 - 235 - knot.database() 236 - .update_repository(commit.did, commit.rkey, commit.rev, commit.cid, &repository) 237 - .await?; 238 - } 239 - CommitEvent::Delete(commit) => { 240 - // First determine whether the repository exists on this knot 241 - if knot 242 - .database() 243 - .resolve_repository(commit.did, commit.rkey) 244 - .await? 245 - .is_none() 246 - { 247 - tracing::debug!( 248 - did = %event.did(), 249 - rkey = %event.rkey(), 250 - "repository is not for this knot, ignoring" 251 - ); 252 - return Ok(()); 253 - }; 254 - 255 - let policy = RepositoryDeletePolicy; 256 - let repository = RepositoryRef::new(commit.did, commit.rkey); 257 - let can_create = policy 258 - .evaluate_access(&commit.did, &Action::RepositoryDelete, &repository, knot) 259 - .await; 260 - 261 - if !matches!(can_create, Granted) { 262 - tracing::warn!(?commit, "RepositoryDelete permission denied"); 263 - return Ok(()); 264 - } 265 - 266 - knot.delete_repo(&commit.try_into()?).await?; 267 - } 268 - } 269 - 270 - tracing::info!("ingested 'sh.tangled.repo' from jetstream"); 271 - Ok(()) 272 - } 273 - 274 - #[tracing::instrument(skip(jetstream, knot))] 275 - async fn process_knot_member( 276 - jetstream: &JetstreamClient, 277 - knot: &Knot, 278 - event: &CommitEvent<'_>, 279 - ) -> anyhow::Result<()> { 280 - match event { 281 - CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 282 - let policy = AddMemberPolicy; 283 - let can_add_knot_member = policy 284 - .evaluate_access(&commit.did, &Action::KnotMemberCreate, knot, knot) 285 - .await; 286 - 287 - if !matches!(can_add_knot_member, Granted) { 288 - tracing::warn!(?commit, "AddKnotMember permission denied"); 289 - return Ok(()); 290 - } 291 - 292 - let Lexicon::KnotMember(member) = serde_json::from_str(commit.record.get())? else { 293 - return Err(anyhow::anyhow!( 294 - "expected a 'sh.tangled.knot.member' record" 295 - )); 296 - }; 297 - 298 - if member.domain != knot.instance_ident() { 299 - return Ok(()); 300 - } 301 - 302 - knot.add_member(commit.rkey, commit.rev, commit.cid, &member) 303 - .await?; 304 - 305 - // Start tracking the DID of the new member. 306 - jetstream.add_did(member.subject).await?; 307 - } 308 - CommitEvent::Delete(delete) => { 309 - let policy = RemoveMemberPolicy; 310 - let can_remove_knot_member = policy 311 - .evaluate_access(&delete.did, &Action::KnotMemberDelete, knot, knot) 312 - .await; 313 - 314 - if !matches!(can_remove_knot_member, Granted) { 315 - tracing::warn!(?delete, "RemoveKnotMember permission denied"); 316 - return Ok(()); 317 - } 318 - 319 - if let Some(did) = knot 320 - .database() 321 - .delete_knot_member(delete.rkey, delete.rev) 322 - .await? 323 - { 324 - // Stop tracking the DID of the new member. 325 - jetstream.remove_did(did).await?; 326 - } 327 - } 328 - } 329 - 330 - tracing::info!("ingested 'sh.tangled.knot.member' record from jetstream"); 331 - Ok(()) 332 - } 333 - 334 - #[tracing::instrument(skip(jetstream, knot))] 335 - async fn process_repo_collaborator( 336 - jetstream: &JetstreamClient, 337 - knot: &Knot, 338 - event: &CommitEvent<'_>, 339 - ) -> anyhow::Result<()> { 340 - match event { 341 - CommitEvent::Create(commit) | CommitEvent::Update(commit) => { 342 - let Lexicon::RepoCollaborator(coll) = serde_json::from_str(commit.record.get())? else { 343 - return Err(anyhow::anyhow!( 344 - "expected a 'sh.tangled.repo.collaborator' record" 345 - )); 346 - }; 347 - 348 - let repo_did = coll.repo.authority(); 349 - let repo_rkey = coll.repo.rkey(); 350 - if coll.repo.collection() != "sh.tangled.repo" { 351 - return Err(anyhow::anyhow!( 352 - "repo parameter in should refer to a 'sh.tangled.repo'" 353 - )); 354 - } 355 - 356 - let policy = AddCollaboratorPolicy; 357 - let repository = RepositoryRef::new(repo_did, repo_rkey); 358 - let can_add_repo_collaborator = policy 359 - .evaluate_access( 360 - &commit.did, 361 - &Action::RepositoryCollaboratorAdd, 362 - &repository, 363 - knot, 364 - ) 365 - .await; 366 - 367 - if !matches!(can_add_repo_collaborator, Granted) { 368 - tracing::warn!(?commit, "RepositoryCollaboratorAdd permission denied"); 369 - return Ok(()); 370 - } 371 - 372 - if knot 373 - .database() 374 - .upsert_repository_collaborator( 375 - commit.did, 376 - commit.rkey, 377 - commit.rev, 378 - commit.cid, 379 - repo_did, 380 - repo_rkey, 381 - coll.subject, 382 - coll.created_at, 383 - ) 384 - .await? 385 - { 386 - jetstream.add_did(coll.subject).await?; 387 - crate::services::seed::public_keys(knot, coll.subject).await?; 388 - } 389 - } 390 - CommitEvent::Delete(delete) => { 391 - let (repo_did, repo_rkey, _) = knot 392 - .database() 393 - .get_repository_collaborator_record(delete.did, delete.rkey) 394 - .await?; 395 - 396 - let policy = RemoveCollaboratorPolicy; 397 - let repository = RepositoryRef::new(&repo_did, &repo_rkey); 398 - let can_remove_repo_collaborator = policy 399 - .evaluate_access( 400 - &delete.did, 401 - &Action::RepositoryCollaboratorDelete, 402 - &repository, 403 - knot, 404 - ) 405 - .await; 406 - 407 - if !matches!(can_remove_repo_collaborator, Granted) { 408 - tracing::warn!(?delete, "RepositoryCollaboratorDelete permission denied"); 409 - return Ok(()); 410 - } 411 - 412 - if let Some(did) = knot 413 - .database() 414 - .delete_repository_collaborator(delete.did, delete.rkey, delete.rev) 415 - .await? 416 - { 417 - jetstream.remove_did(did).await?; 418 - } 419 - } 420 - } 421 - 422 - tracing::info!("ingested 'sh.tangled.repo.collaborator' record from jetstream"); 423 - Ok(()) 424 - }
-254
crates/knot/src/services/rbac.rs
··· 1 - use atproto::Did; 2 - use futures_util::{FutureExt, future::BoxFuture}; 3 - 4 - use crate::{model::KnotState, types::repository_key::RepositoryKey}; 5 - 6 - pub trait Policy<Subject, Resource, Action, Context>: Send + Sync { 7 - /// Evaluates whether access should be granted. 8 - /// 9 - /// # Arguments 10 - /// 11 - /// * `subject` - The entity requesting access. 12 - /// * `action` - The action being performed. 13 - /// * `resource` - The target resource. 14 - /// * `context` - Additional context that may affect the decision. 15 - /// 16 - /// # Returns 17 - /// 18 - /// A [`PolicyResult`] indicating whether access is granted or denied. 19 - fn evaluate_access<'s: 'a, 'a>( 20 - &'s self, 21 - subject: &'a Subject, 22 - action: &'a Action, 23 - resource: &'a Resource, 24 - context: &'a Context, 25 - ) -> BoxFuture<'a, PolicyResult>; 26 - } 27 - 28 - impl<S, R, A, C> Policy<S, R, A, C> for Box<dyn Policy<S, R, A, C>> { 29 - #[inline] 30 - fn evaluate_access<'s: 'a, 'a>( 31 - &'s self, 32 - subject: &'a S, 33 - action: &'a A, 34 - resource: &'a R, 35 - context: &'a C, 36 - ) -> BoxFuture<'a, PolicyResult> { 37 - (**self).evaluate_access(subject, action, resource, context) 38 - } 39 - } 40 - 41 - pub enum PolicyResult { 42 - Granted, 43 - Denied, 44 - } 45 - 46 - pub enum Action { 47 - KnotMemberCreate, 48 - KnotMemberDelete, 49 - RepositoryCollaboratorAdd, 50 - RepositoryCollaboratorDelete, 51 - RepositoryCreate, 52 - RepositoryDelete, 53 - RepositoryEdit, 54 - RepositoryPush, 55 - } 56 - 57 - pub struct RepositoryPushPolicy; 58 - 59 - pub struct RepositoryRef<'a> { 60 - pub did: &'a Did, 61 - pub rkey: &'a str, 62 - } 63 - 64 - impl<'a> RepositoryRef<'a> { 65 - pub fn new(did: &'a Did, rkey: &'a str) -> Self { 66 - Self { did, rkey } 67 - } 68 - } 69 - 70 - impl<'a> From<&'a RepositoryKey> for RepositoryRef<'a> { 71 - fn from(RepositoryKey { owner: did, rkey }: &'a RepositoryKey) -> Self { 72 - Self { did, rkey } 73 - } 74 - } 75 - 76 - impl Policy<&Did, RepositoryKey, Action, KnotState> for RepositoryPushPolicy { 77 - fn evaluate_access<'s: 'a, 'a>( 78 - &'s self, 79 - &subject: &'a &Did, 80 - action: &'a Action, 81 - resource: &'a RepositoryKey, 82 - context: &'a KnotState, 83 - ) -> BoxFuture<'a, PolicyResult> { 84 - async move { 85 - let is_member = subject == resource.owner 86 - || context 87 - .database() 88 - .is_repository_member(&resource.owner, &resource.rkey, subject) 89 - .await 90 - .is_ok_and(|val| val); 91 - 92 - match (action, is_member) { 93 - (Action::RepositoryPush, true) => PolicyResult::Granted, 94 - (_, _) => PolicyResult::Denied, 95 - } 96 - } 97 - .boxed() 98 - } 99 - } 100 - 101 - pub struct AddMemberPolicy; 102 - 103 - impl Policy<&Did, KnotState, Action, KnotState> for AddMemberPolicy { 104 - fn evaluate_access<'s: 'a, 'a>( 105 - &'s self, 106 - &subject: &'a &Did, 107 - action: &'a Action, 108 - resource: &'a KnotState, 109 - _: &'a KnotState, 110 - ) -> BoxFuture<'a, PolicyResult> { 111 - async move { 112 - let is_owner = subject == resource.owner(); 113 - match (action, is_owner) { 114 - (Action::KnotMemberCreate, true) => PolicyResult::Granted, 115 - (_, _) => PolicyResult::Denied, 116 - } 117 - } 118 - .boxed() 119 - } 120 - } 121 - 122 - pub struct RemoveMemberPolicy; 123 - 124 - impl Policy<&Did, KnotState, Action, KnotState> for RemoveMemberPolicy { 125 - fn evaluate_access<'s: 'a, 'a>( 126 - &'s self, 127 - &subject: &'a &Did, 128 - action: &'a Action, 129 - resource: &'a KnotState, 130 - _: &'a KnotState, 131 - ) -> BoxFuture<'a, PolicyResult> { 132 - async move { 133 - let is_owner = subject == resource.owner(); 134 - match (action, is_owner) { 135 - (Action::KnotMemberDelete, true) => PolicyResult::Granted, 136 - (_, _) => PolicyResult::Denied, 137 - } 138 - } 139 - .boxed() 140 - } 141 - } 142 - 143 - pub struct AddCollaboratorPolicy; 144 - 145 - impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for AddCollaboratorPolicy { 146 - fn evaluate_access<'s: 'a, 'a>( 147 - &'s self, 148 - &subject: &'a &Did, 149 - action: &'a Action, 150 - resource: &'a RepositoryRef<'_>, 151 - _: &'a KnotState, 152 - ) -> BoxFuture<'a, PolicyResult> { 153 - // Only repository owners may add collaborators. 154 - async move { 155 - let is_owner = subject == resource.did; 156 - match (action, is_owner) { 157 - (Action::RepositoryCollaboratorAdd, true) => PolicyResult::Granted, 158 - (_, _) => PolicyResult::Denied, 159 - } 160 - } 161 - .boxed() 162 - } 163 - } 164 - 165 - pub struct RemoveCollaboratorPolicy; 166 - 167 - impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RemoveCollaboratorPolicy { 168 - fn evaluate_access<'s: 'a, 'a>( 169 - &'s self, 170 - &subject: &'a &Did, 171 - action: &'a Action, 172 - resource: &'a RepositoryRef<'_>, 173 - _: &'a KnotState, 174 - ) -> BoxFuture<'a, PolicyResult> { 175 - // Only repository owners may remove collaborators. 176 - async move { 177 - let is_owner = subject == resource.did; 178 - match (action, is_owner) { 179 - (Action::RepositoryCollaboratorDelete, true) => PolicyResult::Granted, 180 - (_, _) => PolicyResult::Denied, 181 - } 182 - } 183 - .boxed() 184 - } 185 - } 186 - 187 - pub struct RepositoryCreatePolicy; 188 - 189 - impl Policy<&Did, KnotState, Action, KnotState> for RepositoryCreatePolicy { 190 - fn evaluate_access<'s: 'a, 'a>( 191 - &'s self, 192 - &subject: &'a &Did, 193 - action: &'a Action, 194 - resource: &'a KnotState, 195 - context: &'a KnotState, 196 - ) -> BoxFuture<'a, PolicyResult> { 197 - async move { 198 - let is_member = subject == resource.owner() 199 - || context 200 - .database() 201 - .is_knot_member(subject) 202 - .await 203 - .is_ok_and(|val| val); 204 - 205 - match (action, is_member) { 206 - (Action::RepositoryCreate, true) => PolicyResult::Granted, 207 - (_, _) => PolicyResult::Denied, 208 - } 209 - } 210 - .boxed() 211 - } 212 - } 213 - 214 - pub struct RepositoryDeletePolicy; 215 - 216 - impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RepositoryDeletePolicy { 217 - fn evaluate_access<'s: 'a, 'a>( 218 - &'s self, 219 - &subject: &'a &Did, 220 - action: &'a Action, 221 - resource: &'a RepositoryRef<'_>, 222 - context: &'a KnotState, 223 - ) -> BoxFuture<'a, PolicyResult> { 224 - async move { 225 - let is_owner = subject == context.owner() || resource.did == subject; 226 - match (action, is_owner) { 227 - (Action::RepositoryDelete, true) => PolicyResult::Granted, 228 - (_, _) => PolicyResult::Denied, 229 - } 230 - } 231 - .boxed() 232 - } 233 - } 234 - 235 - pub struct RepositoryEditPolicy; 236 - 237 - impl Policy<&Did, RepositoryRef<'_>, Action, KnotState> for RepositoryEditPolicy { 238 - fn evaluate_access<'s: 'a, 'a>( 239 - &'s self, 240 - &subject: &'a &Did, 241 - action: &'a Action, 242 - resource: &'a RepositoryRef<'_>, 243 - _: &'a KnotState, 244 - ) -> BoxFuture<'a, PolicyResult> { 245 - async move { 246 - let is_repository_owner = resource.did == subject; 247 - match (action, is_repository_owner) { 248 - (Action::RepositoryEdit, true) => PolicyResult::Granted, 249 - (_, _) => PolicyResult::Denied, 250 - } 251 - } 252 - .boxed() 253 - } 254 - }
-109
crates/knot/src/services/seed.rs
··· 1 - use atproto::{Did, tid::Tid}; 2 - use lexicon::{ 3 - com::atproto::repo::list_records::Record, 4 - sh_tangled::{knot::Member, repo::Repo}, 5 - }; 6 - 7 - use crate::{model::Knot, services::atrepo, types::RecordKey}; 8 - 9 - pub async fn all(knot: &Knot) -> anyhow::Result<()> { 10 - let knot = knot.clone(); 11 - let rev = Tid::MIN.to_string(); 12 - 13 - let did = knot.owner(); 14 - 15 - atrepo::fetch_collection::<_, anyhow::Error>( 16 - knot.resolver(), 17 - knot.http(), 18 - did, 19 - "sh.tangled.knot.member", 20 - { 21 - let knot = knot.clone(); 22 - async move |records| { 23 - for Record { uri, cid, value } in records { 24 - let Ok(member) = serde_json::from_str::<Member>(value.get()) else { 25 - continue; 26 - }; 27 - 28 - knot.add_member(uri.rkey(), &rev, &cid, &member).await?; 29 - } 30 - Ok(()) 31 - } 32 - }, 33 - ) 34 - .await?; 35 - 36 - Ok(()) 37 - } 38 - 39 - pub async fn public_keys(knot: &Knot, did: &Did) -> anyhow::Result<()> { 40 - let did = did.to_owned(); 41 - let rev = Tid::MIN.to_string(); 42 - 43 - atrepo::fetch_collection::<_, anyhow::Error>( 44 - knot.resolver(), 45 - knot.http(), 46 - &did.clone(), 47 - "sh.tangled.publicKey", 48 - { 49 - let knot = knot.clone(); 50 - async move |records| { 51 - for Record { uri, cid, value } in records { 52 - let Ok(public_key) = serde_json::from_str(value.get()) else { 53 - continue; 54 - }; 55 - 56 - knot.database() 57 - .upsert_public_key(&did, uri.rkey(), &rev, &cid, &public_key) 58 - .await?; 59 - 60 - tracing::info!(?did, ?uri, "new public key"); 61 - } 62 - 63 - Ok(()) 64 - } 65 - }, 66 - ) 67 - .await?; 68 - 69 - Ok(()) 70 - } 71 - 72 - pub async fn repositories(knot: &Knot, did: &Did) -> anyhow::Result<()> { 73 - let did = did.to_owned(); 74 - 75 - atrepo::fetch_collection::<_, anyhow::Error>( 76 - knot.resolver(), 77 - knot.http(), 78 - &did.clone(), 79 - "sh.tangled.repo", 80 - { 81 - let knot = knot.clone(); 82 - async move |records| { 83 - for record in records { 84 - let Ok(repo) = serde_json::from_str::<Repo>(record.value.get()) else { 85 - tracing::error!(value = ?record.value, "error parsing record value"); 86 - continue; 87 - }; 88 - 89 - if repo.knot != knot.instance_ident() { 90 - continue; 91 - } 92 - 93 - let rec = RecordKey::try_from(record)?; 94 - if let Err(error) = knot.create_repo(&rec, &repo).await { 95 - tracing::error!(?error, ?repo, "failed to create repository"); 96 - continue; 97 - } 98 - 99 - tracing::info!(?did, uri= ?record.uri, name = %repo.name, "new repository"); 100 - } 101 - 102 - Ok(()) 103 - } 104 - }, 105 - ) 106 - .await?; 107 - 108 - Ok(()) 109 - }
crates/knot/src/sync.rs crates/gordian-knot/src/sync.rs
crates/knot/src/sync/tap.rs crates/gordian-knot/src/sync/tap.rs
-99
crates/knot/src/types.rs
··· 1 - use core::fmt; 2 - 3 - use atproto::{Did, RecordUri}; 4 - use lexicon::com::atproto::repo::list_records::Record; 5 - 6 - pub mod push_certificate; 7 - pub mod repository_key; 8 - pub mod repository_path; 9 - pub mod sh_tangled; 10 - 11 - #[derive(Debug)] 12 - pub struct RecordKey<'a> { 13 - pub did: &'a Did, 14 - pub collection: &'a str, 15 - pub rkey: &'a str, 16 - pub rev: &'a str, 17 - pub cid: &'a str, 18 - } 19 - 20 - #[derive(Debug)] 21 - pub struct FromRecordError(&'static str); 22 - 23 - impl fmt::Display for FromRecordError { 24 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 - fmt::Display::fmt(&self.0, f) 26 - } 27 - } 28 - 29 - impl From<&'static str> for FromRecordError { 30 - fn from(value: &'static str) -> Self { 31 - Self(value) 32 - } 33 - } 34 - 35 - impl core::error::Error for FromRecordError {} 36 - 37 - impl<'a> TryFrom<&'a Record> for RecordKey<'a> { 38 - type Error = FromRecordError; 39 - fn try_from(value: &'a Record) -> Result<Self, Self::Error> { 40 - let RecordUri { 41 - authority: did, 42 - collection, 43 - rkey, 44 - } = &value.uri; 45 - 46 - Ok(Self { 47 - did, 48 - collection, 49 - rkey, 50 - rev: "2222222222222", 51 - cid: &value.cid, 52 - }) 53 - } 54 - } 55 - 56 - impl<'a> TryFrom<&'a jetstream::Commit<'a>> for RecordKey<'a> { 57 - type Error = FromRecordError; 58 - 59 - fn try_from(value: &'a jetstream::Commit<'a>) -> Result<Self, Self::Error> { 60 - let jetstream::Commit { 61 - ts: _, 62 - did, 63 - collection, 64 - rkey, 65 - rev, 66 - cid, 67 - record: _, 68 - } = value; 69 - 70 - Ok(Self { 71 - did, 72 - collection, 73 - rkey, 74 - rev, 75 - cid, 76 - }) 77 - } 78 - } 79 - 80 - impl<'a> TryFrom<&'a jetstream::Delete<'a>> for RecordKey<'a> { 81 - type Error = FromRecordError; 82 - fn try_from(value: &'a jetstream::Delete<'a>) -> Result<Self, Self::Error> { 83 - let jetstream::Delete { 84 - ts: _, 85 - did, 86 - collection, 87 - rkey, 88 - rev, 89 - } = value; 90 - 91 - Ok(Self { 92 - did, 93 - collection, 94 - rkey, 95 - rev, 96 - cid: "", 97 - }) 98 - } 99 - }
crates/knot/src/types/push_certificate.rs crates/gordian-knot/src/types/push_certificate.rs
-76
crates/knot/src/types/repository_key.rs
··· 1 - use core::fmt; 2 - 3 - use atproto::did::OwnedDid; 4 - use serde::Deserialize; 5 - 6 - use super::repository_path::{Error, validate}; 7 - 8 - #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize)] 9 - #[serde(try_from = "UnvalidatedRepositoryKey")] 10 - pub struct RepositoryKey { 11 - /// Repository owner's Did. 12 - pub owner: OwnedDid, 13 - 14 - /// Repository record key. 15 - pub rkey: String, 16 - } 17 - 18 - impl RepositoryKey { 19 - pub fn new(owner: impl Into<OwnedDid>, rkey: impl Into<String>) -> Result<Self, Error> { 20 - fn inner(owner: OwnedDid, rkey: String) -> Result<RepositoryKey, Error> { 21 - validate(&owner)?; 22 - validate(&rkey)?; 23 - 24 - Ok(RepositoryKey { owner, rkey }) 25 - } 26 - inner(owner.into(), rkey.into()) 27 - } 28 - } 29 - 30 - #[derive(Deserialize)] 31 - struct UnvalidatedRepositoryKey { 32 - owner: OwnedDid, 33 - rkey: String, 34 - } 35 - 36 - impl TryFrom<UnvalidatedRepositoryKey> for RepositoryKey { 37 - type Error = Error; 38 - fn try_from(value: UnvalidatedRepositoryKey) -> Result<Self, Self::Error> { 39 - let UnvalidatedRepositoryKey { owner, rkey } = value; 40 - validate(&owner)?; 41 - validate(&rkey)?; 42 - Ok(Self { owner, rkey }) 43 - } 44 - } 45 - 46 - impl RepositoryKey { 47 - pub fn owner_str(&self) -> &str { 48 - &self.owner 49 - } 50 - 51 - pub fn rkey(&self) -> &str { 52 - &self.rkey 53 - } 54 - } 55 - 56 - impl fmt::Display for RepositoryKey { 57 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 - write!(f, "{}/{}", self.owner, self.rkey) 59 - } 60 - } 61 - 62 - impl std::str::FromStr for RepositoryKey { 63 - type Err = Error; 64 - fn from_str(s: &str) -> Result<Self, Self::Err> { 65 - let (owner, name) = s.split_once('/').ok_or(Error::Format)?; 66 - 67 - let owner: OwnedDid = owner.parse().map_err(|_| Error::Format)?; 68 - validate(owner.as_str())?; 69 - validate(name)?; 70 - 71 - Ok(Self { 72 - owner, 73 - rkey: name.into(), 74 - }) 75 - } 76 - }
crates/knot/src/types/repository_path.rs crates/gordian-knot/src/types/repository_path.rs
-213
crates/knot/src/types/sh_tangled.rs
··· 1 - pub mod repo { 2 - pub mod branches { 3 - use lexicon::sh_tangled::repo::refs; 4 - use serde::Serialize; 5 - 6 - pub use lexicon::sh_tangled::repo::branches::Input; 7 - 8 - /// Output of `sh.tangled.repo.branches` query. 9 - #[derive(Debug, Default, Serialize)] 10 - #[serde(rename_all = "camelCase")] 11 - pub struct Output { 12 - #[serde(skip_serializing_if = "Vec::is_empty")] 13 - pub branches: Vec<Branch>, 14 - } 15 - 16 - #[derive(Debug, Serialize)] 17 - #[serde(rename_all = "camelCase")] 18 - pub struct Branch { 19 - pub reference: refs::Reference, 20 - pub commit: refs::Commit, 21 - #[serde(rename = "is_deafult")] 22 - pub is_default: bool, 23 - } 24 - 25 - pub type Response = axum::Json<Output>; 26 - } 27 - 28 - pub mod compare { 29 - use lexicon::extra::objectid::ObjectId; 30 - use serde::Serialize; 31 - 32 - pub use lexicon::sh_tangled::repo::compare::Input; 33 - 34 - #[derive(Debug, Serialize)] 35 - pub struct Output { 36 - pub rev1: ObjectId, 37 - pub rev2: ObjectId, 38 - #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] 39 - pub format_patch_raw: String, 40 - // 41 - // @NOTE The real knotserver outputs a few more fields here, but the appview 42 - // doesn't use them. I'm going to save myself some effort and just do the 43 - // minimum for now. 44 - } 45 - } 46 - 47 - pub mod diff { 48 - use lexicon::{extra::objectid::ObjectId, sh_tangled::repo::refs}; 49 - use serde::Serialize; 50 - use std::borrow::Cow; 51 - 52 - pub use lexicon::sh_tangled::repo::diff::Input; 53 - 54 - #[derive(Debug, Serialize)] 55 - pub struct Output { 56 - #[serde(rename = "ref")] 57 - pub rev: Box<str>, 58 - pub diff: NiceDiff, 59 - } 60 - 61 - /// Unified diff replicating Tangled's `NiceDiff` structure. 62 - /// 63 - /// See: <https://tangled.org/@tangled.org/core/blob/master/types/diff.go#L44> 64 - #[derive(Debug, Serialize)] 65 - pub struct NiceDiff { 66 - pub commit: Commit, 67 - pub stat: Stat, 68 - #[serde(rename = "diff")] 69 - pub deltas: Vec<Diff>, 70 - } 71 - 72 - #[derive(Debug, Serialize)] 73 - pub struct Commit { 74 - pub message: String, 75 - pub author: refs::Signature, 76 - pub this: ObjectId, 77 - #[serde(skip_serializing_if = "Option::is_none")] 78 - pub parent: Option<ObjectId>, 79 - #[serde(default)] 80 - pub pgp_signature: Cow<'static, str>, 81 - pub committer: refs::Signature, 82 - pub tree: ObjectId, 83 - #[serde(default)] 84 - pub change_id: Cow<'static, str>, 85 - } 86 - 87 - #[derive(Debug, Default, Serialize)] 88 - pub struct Stat { 89 - pub files_changed: u32, 90 - pub insertions: u32, 91 - pub deletions: u32, 92 - } 93 - 94 - #[derive(Debug, Default, Serialize)] 95 - pub struct Diff { 96 - pub name: Name, 97 - pub text_fragments: Vec<TextFragment>, 98 - pub is_binary: bool, 99 - pub is_new: bool, 100 - pub is_delete: bool, 101 - pub is_copy: bool, 102 - pub is_rename: bool, 103 - } 104 - 105 - #[derive(Debug, Default, Serialize)] 106 - pub struct Name { 107 - pub old: String, 108 - pub new: String, 109 - } 110 - 111 - #[derive(Debug, Default, Serialize)] 112 - #[serde(rename_all = "PascalCase")] 113 - pub struct TextFragment { 114 - #[serde(skip_serializing_if = "str::is_empty")] 115 - pub comment: Cow<'static, str>, 116 - pub old_position: u32, 117 - pub old_lines: u32, 118 - pub new_position: u32, 119 - pub new_lines: u32, 120 - pub lines_added: u32, 121 - pub lines_deleted: u32, 122 - pub leading_context: u32, 123 - pub trailing_context: u32, 124 - #[serde(skip_serializing_if = "Vec::is_empty")] 125 - pub lines: Vec<Line>, 126 - } 127 - 128 - #[derive(Debug)] 129 - pub enum Line { 130 - Context { line: String }, 131 - Addition { line: String }, 132 - Deletion { line: String }, 133 - } 134 - 135 - // Manual impl because this enum is tagged with an integer. 136 - impl Serialize for Line { 137 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 138 - where 139 - S: serde::Serializer, 140 - { 141 - #[derive(Serialize)] 142 - #[serde(rename_all = "PascalCase")] 143 - struct IntTagged<'a> { 144 - op: u8, 145 - line: &'a str, 146 - } 147 - 148 - match self { 149 - Self::Context { line } => IntTagged { op: 0, line }, 150 - Self::Addition { line } => IntTagged { op: 1, line }, 151 - Self::Deletion { line } => IntTagged { op: 2, line }, 152 - } 153 - .serialize(serializer) 154 - } 155 - } 156 - } 157 - 158 - pub mod log { 159 - use lexicon::sh_tangled::repo::refs; 160 - use serde::Serialize; 161 - 162 - pub use lexicon::sh_tangled::repo::log::Input; 163 - 164 - #[derive(Debug, Serialize)] 165 - pub struct Output { 166 - pub commits: Vec<refs::Commit>, 167 - pub log: bool, 168 - pub total: usize, 169 - pub page: usize, 170 - pub per_page: u16, 171 - } 172 - } 173 - 174 - pub mod tags { 175 - use lexicon::{ 176 - extra::objectid::{Array, ObjectId}, 177 - sh_tangled::repo::refs, 178 - }; 179 - use serde::Serialize; 180 - 181 - pub use lexicon::sh_tangled::repo::tags::Input; 182 - 183 - /// Output of `sh.tangled.repo.tags` query. 184 - /// 185 - /// This is not defined in the lexicon, but models what knotserver currently 186 - /// produces. 187 - #[derive(Debug, Serialize)] 188 - pub struct Output { 189 - pub tags: Vec<Tag>, 190 - } 191 - 192 - #[derive(Debug, Serialize)] 193 - #[serde(rename_all = "PascalCase")] 194 - pub struct TagAnnotation { 195 - pub hash: ObjectId<Array>, 196 - pub name: String, 197 - pub tagger: Option<refs::Signature>, 198 - pub message: String, 199 - #[serde(rename = "PGPSignature")] 200 - pub pgp_signature: Option<String>, 201 - pub target_type: i32, 202 - pub target: ObjectId<Array>, 203 - } 204 - 205 - #[derive(Debug, Serialize)] 206 - pub struct Tag { 207 - #[serde(flatten)] 208 - pub r#ref: refs::Reference, 209 - #[serde(rename = "tag", skip_serializing_if = "Option::is_none")] 210 - pub annotation: Option<TagAnnotation>, 211 - } 212 - } 213 - }
crates/knot/src/util.rs crates/gordian-knot/src/util.rs
-20
crates/lexicon/Cargo.toml
··· 1 - [package] 2 - name = "lexicon" 3 - version.workspace = true 4 - authors.workspace = true 5 - repository.workspace = true 6 - license.workspace = true 7 - edition.workspace = true 8 - publish.workspace = true 9 - 10 - [dependencies] 11 - atproto = { workspace = true, features = ["serde"] } 12 - identity.workspace = true 13 - 14 - data-encoding.workspace = true 15 - serde.workspace = true 16 - serde_json.workspace = true 17 - thiserror.workspace = true 18 - time.workspace = true 19 - 20 - gix-hash = "^0.22.0"
crates/lexicon/src/com.rs crates/gordian-lexicon/src/com.rs
crates/lexicon/src/com/atproto.rs crates/gordian-lexicon/src/com/atproto.rs
-47
crates/lexicon/src/com/atproto/repo.rs
··· 1 - pub mod list_records { 2 - //! 3 - //! List a range of records in a repository, matching a specific 4 - //! collection. Does not require auth. 5 - //! 6 - //! <https://docs.bsky.app/docs/api/com-atproto-repo-list-records> 7 - //! 8 - use atproto::RecordUri; 9 - 10 - #[derive(Debug, serde::Deserialize, serde::Serialize)] 11 - pub struct Input { 12 - /// The handle or DID of the repo. 13 - pub repo: String, 14 - 15 - /// The NSID of the record type. 16 - pub collection: String, 17 - 18 - /// The number of records to return. 19 - /// 20 - /// Possible values: 0..=100. 21 - #[serde(skip_serializing_if = "Option::is_none")] 22 - pub limit: Option<usize>, 23 - 24 - #[serde(skip_serializing_if = "Option::is_none")] 25 - pub cursor: Option<String>, 26 - 27 - /// Flag to reverse the order of the returned records. 28 - #[serde(default)] 29 - pub reverse: bool, 30 - } 31 - 32 - #[derive(Debug, serde::Deserialize, serde::Serialize)] 33 - pub struct Output { 34 - pub cursor: Option<String>, 35 - 36 - pub records: Vec<Record>, 37 - } 38 - 39 - #[derive(Debug, serde::Deserialize, serde::Serialize)] 40 - pub struct Record { 41 - pub uri: RecordUri, 42 - 43 - pub cid: String, 44 - 45 - pub value: Box<serde_json::value::RawValue>, 46 - } 47 - }
crates/lexicon/src/extra.rs crates/gordian-lexicon/src/extra.rs
crates/lexicon/src/extra/objectid.rs crates/gordian-lexicon/src/extra/objectid.rs
crates/lexicon/src/lib.rs crates/gordian-lexicon/src/lib.rs
-121
crates/lexicon/src/sh_tangled.rs
··· 1 - //! 2 - //! <https://tangled.org/@tangled.org/core/tree/master/lexicons> 3 - //! 4 - pub mod actor; 5 - pub mod feed; 6 - pub mod git; 7 - pub mod graph; 8 - pub mod knot; 9 - pub mod repo; 10 - pub mod spindle; 11 - pub mod string; 12 - 13 - use atproto::Did; 14 - use serde::{Deserialize, Serialize}; 15 - use std::borrow::Cow; 16 - use time::OffsetDateTime; 17 - 18 - pub mod owner { 19 - use atproto::Did; 20 - use serde::{Deserialize, Serialize}; 21 - 22 - /// XRPC query `sh.tangled.owner` output. 23 - /// 24 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/owner.json> 25 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 26 - pub struct Output<'a> { 27 - #[serde(borrow)] 28 - pub owner: &'a Did, 29 - } 30 - } 31 - 32 - /// `sh.tangled.publicKey` record. 33 - /// 34 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/publicKey.json> 35 - #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 36 - #[serde(rename_all = "camelCase")] 37 - pub struct PublicKey<'a> { 38 - /// Public key contents 39 - #[serde(borrow)] 40 - pub key: Cow<'a, str>, 41 - 42 - /// Human-readable name for this key 43 - pub name: Cow<'a, str>, 44 - 45 - /// Key upload timestamp 46 - #[serde(alias = "created", with = "time::serde::rfc3339")] 47 - pub created_at: OffsetDateTime, 48 - } 49 - 50 - /// `sh.tangled.publicKey` record. 51 - /// 52 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/publicKey.json> 53 - #[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 54 - #[serde(rename_all = "camelCase")] 55 - pub struct PublicKeyOwned { 56 - /// Public key contents 57 - pub key: String, 58 - 59 - /// Human-readable name for this key 60 - pub name: String, 61 - 62 - /// Key upload timestamp 63 - #[serde(alias = "created", with = "time::serde::rfc3339")] 64 - pub created_at: OffsetDateTime, 65 - } 66 - 67 - impl<'a> From<&'a PublicKeyOwned> for PublicKey<'a> { 68 - fn from(value: &'a PublicKeyOwned) -> Self { 69 - Self { 70 - key: Cow::Borrowed(&value.key), 71 - name: Cow::Borrowed(&value.name), 72 - created_at: value.created_at, 73 - } 74 - } 75 - } 76 - 77 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 78 - #[serde(rename_all = "camelCase")] 79 - pub struct Pipeline<'a> { 80 - trigger_metadata: TriggerMetadata<'a>, 81 - } 82 - 83 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 84 - #[serde(rename_all = "camelCase")] 85 - pub struct TriggerMetadata<'a> { 86 - pub kind: Cow<'a, str>, 87 - } 88 - 89 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 90 - #[serde(rename_all = "camelCase")] 91 - pub struct TriggerRepo<'a> { 92 - #[serde(borrow)] 93 - pub knot: Cow<'a, str>, 94 - 95 - pub did: Cow<'a, Did>, 96 - 97 - pub repo: Cow<'a, str>, 98 - 99 - pub default_branch: Cow<'a, str>, 100 - } 101 - 102 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 103 - #[serde(rename_all = "camelCase")] 104 - pub struct Workflow<'a> { 105 - #[serde(borrow)] 106 - pub name: Cow<'a, str>, 107 - 108 - pub engine: Cow<'a, str>, 109 - 110 - pub clone: CloneOptions, 111 - 112 - pub raw: Cow<'a, str>, 113 - } 114 - 115 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 116 - #[serde(rename_all = "camelCase")] 117 - pub struct CloneOptions { 118 - pub skip: bool, 119 - pub depth: u64, 120 - pub submodules: bool, 121 - }
crates/lexicon/src/sh_tangled/actor.rs crates/gordian-lexicon/src/sh_tangled/actor.rs
-35
crates/lexicon/src/sh_tangled/feed.rs
··· 1 - //! 2 - //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/feed> 3 - //! 4 - use atproto::RecordUri; 5 - use serde::{Deserialize, Serialize}; 6 - use std::borrow::Cow; 7 - use time::OffsetDateTime; 8 - 9 - /// `sh.tangled.feed.reaction` record. 10 - /// 11 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/feed/reaction.json> 12 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 13 - #[serde(rename_all = "camelCase")] 14 - pub struct Reaction<'a> { 15 - #[serde(alias = "created", with = "time::serde::rfc3339")] 16 - pub created_at: OffsetDateTime, 17 - 18 - #[serde(borrow)] 19 - pub reaction: Cow<'a, str>, 20 - 21 - pub subject: Cow<'a, RecordUri>, 22 - } 23 - 24 - /// `sh.tangled.feed.star` record. 25 - /// 26 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/feed/star.json> 27 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 28 - #[serde(rename_all = "camelCase")] 29 - pub struct Star { 30 - #[serde(alias = "created", with = "time::serde::rfc3339")] 31 - pub created_at: OffsetDateTime, 32 - 33 - // @TODO This should be an at-uri. 34 - pub subject: RecordUri, 35 - }
-108
crates/lexicon/src/sh_tangled/git.rs
··· 1 - use atproto::did::OwnedDid; 2 - use serde::{Deserialize, Serialize}; 3 - 4 - use crate::extra::objectid::ObjectId; 5 - 6 - /// `sh.tangled.git.refUpdate` record 7 - /// 8 - /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/git/refUpdate.json> 9 - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 10 - #[serde(rename_all = "camelCase")] 11 - pub struct RefUpdate { 12 - /// Ref being updated. 13 - pub r#ref: String, 14 - 15 - /// DID of the user that push this ref. 16 - pub committer_did: OwnedDid, 17 - 18 - /// DID of the owner of the repo. 19 - pub repo_did: OwnedDid, 20 - 21 - /// Name of the repo. 22 - pub repo_name: String, 23 - 24 - /// Old SHA of this ref. 25 - pub old_sha: ObjectId, 26 - 27 - /// New SHA of this ref. 28 - pub new_sha: ObjectId, 29 - 30 - pub meta: Meta, 31 - } 32 - 33 - #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 34 - #[serde(rename_all = "camelCase")] 35 - pub struct Meta { 36 - pub is_default_ref: bool, 37 - 38 - pub lang_breakdown: LanguageBreakdown, 39 - 40 - pub commit_count: CommitCountBreakdown, 41 - } 42 - 43 - #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 44 - #[serde(rename_all = "camelCase")] 45 - pub struct LanguageBreakdown { 46 - #[serde(default)] 47 - pub inputs: Vec<Language>, 48 - } 49 - 50 - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 51 - #[serde(rename_all = "camelCase")] 52 - pub struct Language { 53 - pub lang: String, 54 - pub size: u64, 55 - } 56 - 57 - #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 58 - #[serde(rename_all = "camelCase")] 59 - pub struct CommitCountBreakdown { 60 - #[serde(default)] 61 - pub by_email: Vec<CommitCount>, 62 - } 63 - 64 - #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 65 - #[serde(rename_all = "camelCase")] 66 - pub struct CommitCount { 67 - pub email: String, 68 - pub count: u64, 69 - } 70 - 71 - #[cfg(test)] 72 - mod tests { 73 - use super::RefUpdate; 74 - 75 - #[test] 76 - fn can_deserialize() { 77 - const SAMPLE: &str = r#" 78 - { 79 - "$type": "", 80 - "committerDid": "did:plc:65gha4t3avpfpzmvpbwovss7", 81 - "meta": { 82 - "commitCount": { 83 - "byEmail": [ 84 - { 85 - "count": 1, 86 - "email": "x@tjh.dev" 87 - } 88 - ] 89 - }, 90 - "isDefaultRef": true, 91 - "langBreakdown": {} 92 - }, 93 - "newSha": "74771f21d2d409e7014af3206839fda30a429311", 94 - "oldSha": "0000000000000000000000000000000000000000", 95 - "ref": "refs/heads/main", 96 - "repoDid": "did:plc:65gha4t3avpfpzmvpbwovss7", 97 - "repoName": "lol" 98 - } 99 - 100 - "#; 101 - 102 - let update: RefUpdate = serde_json::from_str(SAMPLE).unwrap(); 103 - assert_eq!( 104 - update.committer_did.as_str(), 105 - "did:plc:65gha4t3avpfpzmvpbwovss7" 106 - ); 107 - } 108 - }
-51
crates/lexicon/src/sh_tangled/graph.rs
··· 1 - //! 2 - //! <https://tangled.org/tangled.org/core/tree/master/lexicons/graph> 3 - //! 4 - use std::borrow::Cow; 5 - 6 - /// `sh.tangled.graph.follow` record. 7 - /// 8 - /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/graph/follow.json> 9 - /// 10 - #[derive(Debug, Hash, PartialEq, Eq, serde::Deserialize, serde::Serialize)] 11 - #[serde(rename_all = "camelCase")] 12 - pub struct Follow<'a> { 13 - #[serde(borrow, with = "atproto::serde::cow_did")] 14 - pub subject: Cow<'a, atproto::Did>, 15 - 16 - #[serde(with = "time::serde::rfc3339")] 17 - pub created_at: time::OffsetDateTime, 18 - } 19 - 20 - #[test] 21 - fn can_deserialize_follow() -> Result<(), serde_json::Error> { 22 - #[derive(serde::Deserialize)] 23 - #[serde(tag = "$type")] 24 - #[allow(unused)] 25 - enum Lexicon<'a> { 26 - #[serde(rename = "sh.tangled.graph.follow")] 27 - Follow(#[serde(borrow)] Follow<'a>), 28 - } 29 - 30 - fn check(s: &str) -> Result<Lexicon<'_>, serde_json::Error> { 31 - serde_json::from_str(s) 32 - } 33 - 34 - check( 35 - r#"{ 36 - "$type": "sh.tangled.graph.follow", 37 - "subject": "did:plc:wshs7t2adsemcrrd4snkeqli", 38 - "createdAt":"2025-04-17T12:37:42Z" 39 - }"#, 40 - )?; 41 - 42 - check( 43 - r#"{ 44 - "$type": "sh.tangled.graph.follow", 45 - "subject": "did:plc:xasnlahkri4ewmbuzly2rlc5", 46 - "createdAt": "2026-01-16T14:39:26+02:00" 47 - }"#, 48 - )?; 49 - 50 - Ok(()) 51 - }
-53
crates/lexicon/src/sh_tangled/knot.rs
··· 1 - use atproto::did::Did; 2 - use serde::{Deserialize, Serialize}; 3 - use std::borrow::Cow; 4 - use time::OffsetDateTime; 5 - 6 - pub mod version { 7 - use serde::{Deserialize, Serialize}; 8 - use std::borrow::Cow; 9 - 10 - /// XRPC query `sh.tangled.knot.version` output. 11 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 12 - #[serde(rename_all = "camelCase")] 13 - pub struct Output { 14 - #[serde(borrow)] 15 - pub version: Cow<'static, str>, 16 - } 17 - } 18 - 19 - /// `sh.tangled.knot` record. 20 - /// 21 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/knot/knot.json> 22 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 23 - #[serde(rename = "sh.tangled.knot", rename_all = "camelCase")] 24 - pub struct Knot { 25 - #[serde(with = "time::serde::rfc3339")] 26 - pub created_at: OffsetDateTime, 27 - } 28 - 29 - /// `sh.tangled.knot.member` record. 30 - /// 31 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/knot/member.json> 32 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 33 - #[serde(rename = "sh.tangled.knot.member", rename_all = "camelCase")] 34 - pub struct Member<'a> { 35 - #[serde(borrow)] 36 - pub subject: Cow<'a, Did>, 37 - 38 - /// Domain that this member now belongs to 39 - pub domain: Cow<'a, str>, 40 - 41 - #[serde(with = "time::serde::rfc3339")] 42 - pub created_at: OffsetDateTime, 43 - } 44 - 45 - impl<'a> Member<'a> { 46 - pub fn new(subject: &'a Did, domain: &'a str, created_at: OffsetDateTime) -> Self { 47 - Self { 48 - subject: subject.into(), 49 - domain: domain.into(), 50 - created_at, 51 - } 52 - } 53 - }
-197
crates/lexicon/src/sh_tangled/repo.rs
··· 1 - pub mod archive; 2 - pub mod blob; 3 - pub mod branch; 4 - pub mod branches; 5 - pub mod compare; 6 - pub mod create; 7 - pub mod delete; 8 - pub mod diff; 9 - pub mod get_default_branch; 10 - pub mod issue; 11 - pub mod languages; 12 - pub mod log; 13 - pub mod merge_check; 14 - pub mod pull; 15 - pub mod set_default_branch; 16 - pub mod tags; 17 - pub mod tree; 18 - 19 - use atproto::{Did, OwnedDid, RecordUri}; 20 - use serde::{Deserialize, Serialize}; 21 - use std::borrow::Cow; 22 - use time::OffsetDateTime; 23 - 24 - use crate::extra::objectid::ObjectId; 25 - 26 - /// `sh.tangled.repo.issue` record. 27 - /// 28 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/collaborator.json> 29 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 30 - #[serde(rename = "sh.tangled.repo.collaborator", rename_all = "camelCase")] 31 - pub struct Collaborator<'a> { 32 - #[serde(borrow)] 33 - pub subject: &'a Did, 34 - 35 - /// Domain that this member now belongs to 36 - pub repo: RecordUri, 37 - 38 - #[serde(with = "time::serde::rfc3339")] 39 - pub created_at: OffsetDateTime, 40 - } 41 - 42 - /// `sh.tangled.repo.issue` record. 43 - /// 44 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/issue.json> 45 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 46 - #[serde(rename_all = "camelCase")] 47 - pub struct Issue<'a> { 48 - pub repo: RecordUri, 49 - 50 - #[serde(borrow)] 51 - pub title: Cow<'a, str>, 52 - 53 - #[serde(with = "time::serde::rfc3339")] 54 - pub created_at: OffsetDateTime, 55 - 56 - #[serde(skip_serializing_if = "Option::is_none")] 57 - pub body: Option<Cow<'a, str>>, 58 - 59 - #[serde(skip_serializing_if = "Vec::is_empty")] 60 - pub mentions: Vec<OwnedDid>, 61 - 62 - #[serde(skip_serializing_if = "Vec::is_empty")] 63 - pub references: Vec<RecordUri>, 64 - } 65 - 66 - /// `sh.tangled.repo` record. 67 - /// 68 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/repo.json> 69 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 70 - #[serde(rename_all = "camelCase")] 71 - pub struct Repo<'a> { 72 - /// Name of the repo 73 - #[serde(borrow)] 74 - pub name: Cow<'a, str>, 75 - 76 - /// Knot where the repo was created 77 - pub knot: Cow<'a, str>, 78 - 79 - /// CI runner to send jobs to and receive results from 80 - #[serde(skip_serializing_if = "Option::is_none")] 81 - pub spindle: Option<Cow<'a, str>>, 82 - 83 - #[serde(skip_serializing_if = "Option::is_none")] 84 - pub description: Option<Cow<'a, str>>, 85 - 86 - pub website: Option<Cow<'a, str>>, 87 - 88 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 89 - pub topics: Vec<Cow<'a, str>>, 90 - 91 - /// Source of the repo 92 - #[serde(skip_serializing_if = "Option::is_none")] 93 - pub source: Option<Cow<'a, str>>, 94 - 95 - /// List of labels that this repo subscribes to 96 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 97 - pub labels: Vec<RecordUri>, 98 - 99 - #[serde(with = "time::serde::rfc3339")] 100 - #[serde(alias = "addedAt")] 101 - pub created_at: OffsetDateTime, 102 - } 103 - 104 - /// `sh.tangled.repo.pull` record. 105 - /// 106 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/pulls/pull.json> 107 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 108 - #[serde(rename = "sh.tangled.repo.pull", rename_all = "camelCase")] 109 - pub struct Pull<'a> { 110 - #[serde(borrow)] 111 - pub target: PullTarget<'a>, 112 - 113 - pub title: Cow<'a, str>, 114 - 115 - #[serde(skip_serializing_if = "Option::is_none")] 116 - pub body: Option<Cow<'a, str>>, 117 - 118 - pub patch: Cow<'a, str>, 119 - 120 - #[serde(skip_serializing_if = "Option::is_none")] 121 - pub source: Option<PullSource<'a>>, 122 - 123 - #[serde(with = "time::serde::rfc3339")] 124 - pub created_at: OffsetDateTime, 125 - 126 - #[serde(skip_serializing_if = "Vec::is_empty")] 127 - pub mentions: Vec<OwnedDid>, 128 - 129 - #[serde(skip_serializing_if = "Vec::is_empty")] 130 - pub references: Vec<RecordUri>, 131 - } 132 - 133 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 134 - pub struct PullTarget<'a> { 135 - pub repo: RecordUri, 136 - 137 - #[serde(borrow)] 138 - pub branch: Cow<'a, str>, 139 - } 140 - 141 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 142 - pub struct PullSource<'a> { 143 - #[serde(borrow)] 144 - pub branch: Cow<'a, str>, 145 - 146 - pub sha: ObjectId, 147 - 148 - #[serde(skip_serializing_if = "Option::is_none")] 149 - pub repo: Option<RecordUri>, 150 - } 151 - 152 - /// Lexicon sub-types. 153 - pub mod refs { 154 - use crate::extra::objectid::{Array, ObjectId}; 155 - use serde::Serialize; 156 - use std::{borrow::Cow, collections::HashMap}; 157 - use time::OffsetDateTime; 158 - 159 - #[derive(Debug, Default, Serialize)] 160 - #[serde(rename_all = "camelCase")] 161 - pub struct Reference { 162 - /// Short-name of the reference. 163 - pub name: String, 164 - pub hash: ObjectId, 165 - } 166 - 167 - /// Git commit signature (ie. the author or committer). 168 - #[derive(Debug, Serialize)] 169 - pub struct Signature { 170 - /// Author or committer name. 171 - pub name: String, 172 - 173 - /// Author or committer email. 174 - pub email: String, 175 - 176 - /// Author or committer timestamp. 177 - #[serde(with = "time::serde::rfc3339")] 178 - pub when: OffsetDateTime, 179 - } 180 - 181 - #[derive(Debug, Serialize)] 182 - #[serde(rename_all = "PascalCase")] 183 - pub struct Commit { 184 - pub hash: ObjectId<Array>, 185 - pub author: Signature, 186 - pub committer: Signature, 187 - pub merge_tag: String, 188 - #[serde(rename = "PGPSignature")] 189 - pub pgp_signature: Option<String>, 190 - pub message: String, 191 - pub tree_hash: ObjectId<Array>, 192 - pub parent_hashes: Vec<ObjectId<Array>>, 193 - pub encoding: Cow<'static, str>, 194 - /// Non-standard object headers. Values are always base64 encoded. 195 - pub extra_headers: HashMap<String, String>, 196 - } 197 - }
crates/lexicon/src/sh_tangled/repo/archive.rs crates/gordian-lexicon/src/sh_tangled/repo/archive.rs
crates/lexicon/src/sh_tangled/repo/blob.rs crates/gordian-lexicon/src/sh_tangled/repo/blob.rs
crates/lexicon/src/sh_tangled/repo/branch.rs crates/gordian-lexicon/src/sh_tangled/repo/branch.rs
crates/lexicon/src/sh_tangled/repo/branches.rs crates/gordian-lexicon/src/sh_tangled/repo/branches.rs
crates/lexicon/src/sh_tangled/repo/compare.rs crates/gordian-lexicon/src/sh_tangled/repo/compare.rs
crates/lexicon/src/sh_tangled/repo/create.rs crates/gordian-lexicon/src/sh_tangled/repo/create.rs
-38
crates/lexicon/src/sh_tangled/repo/delete.rs
··· 1 - //! 2 - //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 3 - //! 4 - 5 - use atproto::did::OwnedDid; 6 - use serde::{Deserialize, Serialize}; 7 - 8 - /// Parameters for the `sh.tangled.repo.delete` procedure. 9 - /// 10 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/delete.json> 11 - #[derive(Debug, Deserialize, Serialize)] 12 - #[serde(rename_all = "camelCase")] 13 - pub struct Input { 14 - /// DID of the repository owner. 15 - pub did: OwnedDid, 16 - 17 - /// Record key of the repository record. 18 - pub rkey: String, 19 - 20 - /// Name of the repository to delete. 21 - pub name: String, 22 - } 23 - 24 - #[cfg(test)] 25 - mod tests { 26 - use super::Input; 27 - 28 - #[test] 29 - fn can_deserialize_required() { 30 - const REQUEST: &str = 31 - r#"{"did":"did:plc:65gha4t3avpfpzmvpbwovss7","rkey":"3m24udbjajf22","name":"gordian"}"#; 32 - 33 - let input: Input = serde_json::from_str(REQUEST).expect("should deserialize"); 34 - assert_eq!(input.did.as_str(), "did:plc:65gha4t3avpfpzmvpbwovss7"); 35 - assert_eq!(input.rkey, "3m24udbjajf22"); 36 - assert_eq!(input.name, "gordian"); 37 - } 38 - }
crates/lexicon/src/sh_tangled/repo/diff.rs crates/gordian-lexicon/src/sh_tangled/repo/diff.rs
crates/lexicon/src/sh_tangled/repo/get_default_branch.rs crates/gordian-lexicon/src/sh_tangled/repo/get_default_branch.rs
-44
crates/lexicon/src/sh_tangled/repo/issue.rs
··· 1 - //! 2 - //! <https://tangled.org/@tangled.org/core/tree/master/lexicons/issue> 3 - //! 4 - use atproto::{OwnedDid, RecordUri}; 5 - use serde::{Deserialize, Serialize}; 6 - use std::borrow::Cow; 7 - use time::OffsetDateTime; 8 - 9 - /// `sh.tangled.repo.issue.comment` record. 10 - /// 11 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/comment.json> 12 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 13 - #[serde(rename_all = "camelCase")] 14 - pub struct Comment<'a> { 15 - #[serde(borrow)] 16 - pub issue: Cow<'a, str>, 17 - 18 - pub body: Cow<'a, str>, 19 - 20 - #[serde(with = "time::serde::rfc3339")] 21 - pub created_at: OffsetDateTime, 22 - 23 - #[serde(skip_serializing_if = "Option::is_none")] 24 - pub reply_to: Option<RecordUri>, 25 - 26 - #[serde(skip_serializing_if = "Vec::is_empty")] 27 - pub mentions: Vec<OwnedDid>, 28 - 29 - #[serde(skip_serializing_if = "Vec::is_empty")] 30 - pub references: Vec<RecordUri>, 31 - } 32 - 33 - /// `sh.tangled.repo.issue.state` record. 34 - /// 35 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/issue/state.json> 36 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 37 - #[serde(rename_all = "camelCase")] 38 - pub struct State<'a> { 39 - pub issue: RecordUri, 40 - 41 - /// State of the issue. 42 - #[serde(borrow)] 43 - pub state: Cow<'a, str>, 44 - }
crates/lexicon/src/sh_tangled/repo/languages.rs crates/gordian-lexicon/src/sh_tangled/repo/languages.rs
crates/lexicon/src/sh_tangled/repo/log.rs crates/gordian-lexicon/src/sh_tangled/repo/log.rs
-52
crates/lexicon/src/sh_tangled/repo/merge_check.rs
··· 1 - //! 2 - //! <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/mergeCheck.json> 3 - //! 4 - use std::borrow::Cow; 5 - 6 - use atproto::did::OwnedDid; 7 - use serde::{Deserialize, Serialize}; 8 - 9 - /// Parameters for the `sh.tangled.repo.mergeCheck` query. 10 - /// 11 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/repo/mergeCheck.json> 12 - #[derive(Debug, Deserialize)] 13 - pub struct Input { 14 - /// DID of the repository owner 15 - pub did: OwnedDid, 16 - 17 - /// Name of the repository 18 - pub name: String, 19 - 20 - /// Patch or pull request to check for merge conflicts 21 - pub patch: String, 22 - 23 - /// Target branch to merge into 24 - pub branch: String, 25 - } 26 - 27 - #[derive(Debug, Serialize)] 28 - pub struct Output { 29 - /// Whether the merge as conflicts 30 - pub is_conflicted: bool, 31 - 32 - /// List of files with merge conflicts 33 - #[serde(skip_serializing_if = "Vec::is_empty")] 34 - pub conflicts: Vec<ConflictInfo>, 35 - 36 - /// Additional message about the merge check 37 - #[serde(skip_serializing_if = "Option::is_none")] 38 - pub message: Option<Cow<'static, str>>, 39 - 40 - /// Error message if check failed 41 - #[serde(skip_serializing_if = "Option::is_none")] 42 - pub error: Option<Cow<'static, str>>, 43 - } 44 - 45 - #[derive(Debug, Serialize)] 46 - pub struct ConflictInfo { 47 - /// Name of the conflicted file 48 - pub filename: String, 49 - 50 - /// Reason for the conflict 51 - pub reason: Cow<'static, str>, 52 - }
-38
crates/lexicon/src/sh_tangled/repo/pull.rs
··· 1 - //! 2 - //! <https://tangled.org/tangled.org/core/tree/master/lexicons/pulls> 3 - //! 4 - use std::borrow::Cow; 5 - 6 - use serde::{Deserialize, Serialize}; 7 - 8 - /// `sh.tangled.repo.pull.comment` record. 9 - /// 10 - /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/comment.json> 11 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 12 - #[serde(rename_all = "camelCase")] 13 - pub struct Comment<'a> { 14 - pub pull: atproto::RecordUri, 15 - 16 - #[serde(borrow)] 17 - pub body: Cow<'a, str>, 18 - 19 - #[serde(with = "time::serde::rfc3339")] 20 - pub created_at: time::OffsetDateTime, 21 - 22 - #[serde(skip_serializing_if = "Vec::is_empty")] 23 - pub mentions: Vec<atproto::OwnedDid>, 24 - 25 - #[serde(skip_serializing_if = "Vec::is_empty")] 26 - pub references: Vec<atproto::RecordUri>, 27 - } 28 - 29 - /// `sh.tangled.repo.pull.state` record. 30 - /// 31 - /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/state.json> 32 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 33 - pub struct Status<'a> { 34 - pub pull: atproto::RecordUri, 35 - 36 - #[serde(borrow)] 37 - pub status: Cow<'a, str>, 38 - }
-14
crates/lexicon/src/sh_tangled/repo/set_default_branch.rs
··· 1 - use atproto::RecordUri; 2 - use serde::Deserialize; 3 - 4 - /// Parameters for the `sh.tangled.repo.setDefaultBranch` procedure. 5 - /// 6 - /// See: <https://tangled.org/tangled.org/core/blob/master/lexicons/repo/defaultBranch.json> 7 - /// 8 - #[derive(Debug, Deserialize)] 9 - #[serde(rename_all = "camelCase")] 10 - pub struct Input { 11 - pub repo: RecordUri, 12 - 13 - pub default_branch: String, 14 - }
crates/lexicon/src/sh_tangled/repo/tags.rs crates/gordian-lexicon/src/sh_tangled/repo/tags.rs
crates/lexicon/src/sh_tangled/repo/tree.rs crates/gordian-lexicon/src/sh_tangled/repo/tree.rs
-30
crates/lexicon/src/sh_tangled/spindle.rs
··· 1 - use atproto::did::Did; 2 - use serde::{Deserialize, Serialize}; 3 - use std::borrow::Cow; 4 - use time::OffsetDateTime; 5 - 6 - /// `sh.tangled.spindle` record. 7 - /// 8 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/spindle/spindle.json> 9 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 10 - #[serde(rename_all = "camelCase")] 11 - pub struct Spindle { 12 - #[serde(with = "time::serde::rfc3339")] 13 - pub created_at: OffsetDateTime, 14 - } 15 - 16 - /// `sh.tangled.spindle.member` record. 17 - /// 18 - /// <https://tangled.org/@tangled.org/core/blob/master/lexicons/spindle/member.json> 19 - #[derive(Debug, Hash, PartialEq, Eq, Deserialize, Serialize)] 20 - #[serde(rename_all = "camelCase")] 21 - pub struct Member<'a> { 22 - #[serde(borrow, with = "atproto::serde::cow_did")] 23 - pub subject: Cow<'a, Did>, 24 - 25 - /// Spindle instance that the subject is now a member of. 26 - pub instance: Cow<'a, str>, 27 - 28 - #[serde(with = "time::serde::rfc3339")] 29 - pub created_at: OffsetDateTime, 30 - }
crates/lexicon/src/sh_tangled/string.rs crates/gordian-lexicon/src/sh_tangled/string.rs
+4 -3
crates/mock-pds/Cargo.toml
··· 8 8 publish.workspace = true 9 9 10 10 [dependencies] 11 - atproto = { workspace = true, features = ["serde", "sqlx"] } 12 - auth.workspace = true 11 + gordian-types = { workspace = true, features = ["serde", "sqlx"] } 12 + gordian-auth = { workspace = true } 13 + gordian-identity = { workspace = true } 14 + 13 15 aws-lc-rs = "1.15.4" 14 16 axum.workspace = true 15 17 data-encoding.workspace = true 16 18 futures-util = "0.3.31" 17 - identity.workspace = true 18 19 multibase = "0.9.2" 19 20 serde = { workspace = true, features = ["derive"] } 20 21 serde_json.workspace = true
+1 -1
crates/mock-pds/src/api.rs
··· 9 9 10 10 pub mod com_atproto { 11 11 pub mod repo { 12 - use atproto::did::OwnedDid; 13 12 use axum::{ 14 13 Json, Router, 15 14 extract::{FromRef, Query, State}, 16 15 http::StatusCode, 17 16 response::IntoResponse, 18 17 }; 18 + use gordian_types::OwnedDid; 19 19 use serde_json::Value; 20 20 use sqlx::Row as _; 21 21
+21 -19
crates/mock-pds/src/state.rs
··· 1 1 use std::{fmt::Debug, net::SocketAddr, sync::Arc}; 2 2 3 - use atproto::{did::OwnedDid, tid::Tid}; 4 - use auth::jwt::{Claims, Header}; 5 3 use aws_lc_rs::{ 6 4 encoding::{AsBigEndian as _, EcPublicKeyCompressedBin}, 7 5 rand::SystemRandom, 8 6 signature::{ECDSA_P256K1_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _}, 9 7 }; 10 8 use futures_util::FutureExt as _; 11 - use identity::DidDocument; 9 + use gordian_auth::jwt; 10 + use gordian_identity::DidDocument; 11 + use gordian_types::{OwnedDid, Tid}; 12 12 use sqlx::{ 13 13 SqlitePool, 14 14 sqlite::{SqliteConnectOptions, SqlitePoolOptions}, ··· 47 47 /// The internal address of the mock PDS will be set as the "#atproto_pds" service for 48 48 /// the new identity. 49 49 /// 50 - pub async fn insert_identity(&self, did: &atproto::Did, handle: &str) { 50 + pub async fn insert_identity(&self, did: &gordian_types::Did, handle: &str) { 51 51 let mut doc = DidDocument::new(did, handle).expect("valid did for did document"); 52 - doc.service 53 - .push(identity::Service::atproto_pds(self.service_endpoint())); 52 + doc.service.push(gordian_identity::Service::atproto_pds( 53 + self.service_endpoint(), 54 + )); 54 55 55 56 // Generate a key pair and encode the public key as verification method for 56 57 // the mock user. ··· 61 60 key_data.extend_from_slice(public_key.as_ref()); 62 61 let public_key_multibase = multibase::encode(multibase::Base::Base58Btc, key_data); 63 62 doc.verification_method 64 - .push(identity::VerificationMethod::Multikey { 63 + .push(gordian_identity::VerificationMethod::Multikey { 65 64 id: format!("{}#atproto", doc.id), 66 65 controller: doc.id.clone(), 67 66 public_key_multibase, ··· 84 83 85 84 pub async fn insert_record<T>( 86 85 &self, 87 - repo: &atproto::Did, 86 + repo: &gordian_types::Did, 88 87 collection: &str, 89 88 rkey: &str, 90 89 value: &T, ··· 120 119 } 121 120 122 121 // Create an inter-service auth header for an account in the fake PDS. 123 - pub async fn service_auth(&self, claims: &Claims) -> String { 122 + pub async fn service_auth(&self, claims: &jwt::Claims) -> String { 124 123 use data_encoding::BASE64URL_NOPAD as Encoding; 125 124 use sqlx::Row as _; 126 125 127 126 let mut token = String::new(); 128 127 let header = Encoding.encode( 129 - &serde_json::to_vec(&Header { 130 - typ: auth::jwt::Type::JWT, 131 - alg: auth::jwt::Algorithm::ES256K, 128 + &serde_json::to_vec(&jwt::Header { 129 + typ: jwt::Type::JWT, 130 + alg: jwt::Algorithm::ES256K, 132 131 crv: None, 133 132 }) 134 133 .unwrap(), ··· 169 168 } 170 169 } 171 170 172 - impl identity::ResolveIdentity for Pds { 171 + impl gordian_identity::ResolveIdentity for Pds { 173 172 fn resolve_handle<'s: 'h, 'h>( 174 173 &'s self, 175 174 handle: &'h str, 176 - ) -> futures_util::future::BoxFuture<'h, Result<OwnedDid, identity::ResolveError>> { 175 + ) -> futures_util::future::BoxFuture<'h, Result<OwnedDid, gordian_identity::ResolveError>> { 177 176 use sqlx::Row as _; 178 177 async move { 179 178 let result = sqlx::query("SELECT did FROM identity WHERE handle = ?") ··· 181 180 .fetch_one(self.db()) 182 181 .await 183 182 .inspect_err(|error| eprintln!("{error:?}")) 184 - .map_err(|_| identity::ResolveError::UnresolvedHandle)?; 183 + .map_err(|_| gordian_identity::ResolveError::UnresolvedHandle)?; 185 184 186 - let did: &atproto::Did = result.get("did"); 185 + let did: &gordian_types::Did = result.get("did"); 187 186 Ok(did.to_owned()) 188 187 } 189 188 .boxed() ··· 191 190 192 191 fn resolve_did<'s: 'd, 'd>( 193 192 &'s self, 194 - did: &'d atproto::Did, 195 - ) -> futures_util::future::BoxFuture<'d, Result<DidDocument, identity::ResolveError>> { 193 + did: &'d gordian_types::Did, 194 + ) -> futures_util::future::BoxFuture<'d, Result<DidDocument, gordian_identity::ResolveError>> 195 + { 196 196 use sqlx::Row as _; 197 197 async move { 198 198 let result = sqlx::query("SELECT doc FROM identity WHERE did = ?") ··· 201 199 .fetch_one(self.db()) 202 200 .await 203 201 .inspect_err(|error| eprintln!("{error:?}")) 204 - .map_err(|_| identity::ResolveError::UnresolvedHandle)?; 202 + .map_err(|_| gordian_identity::ResolveError::UnresolvedHandle)?; 205 203 206 204 let doc: &str = result.get("doc"); 207 205 let doc = serde_json::from_str(doc).unwrap();
+5 -4
justfile
··· 1 + bin := "gordian-knot" 1 2 host := "helr01:gordian-knot" 2 3 target := "x86_64-unknown-linux-gnu" 3 4 4 5 build: 5 - cross build --release --target {{target}} --package knot 6 + cross build --release --target {{target}} --package {{bin}} 6 7 7 8 build-compress: build 8 - {{require("upx")}} target/{{target}}/release/knot 9 + {{require("upx")}} target/{{target}}/release/{{bin}} 9 10 10 11 deployffs: build 11 - incus exec {{host}} -- unlink /usr/bin/knot 12 - incus file push target/{{target}}/release/knot {{host}}/usr/bin/knot 12 + incus exec {{host}} -- unlink /usr/bin/{{bin}} 13 + incus file push target/{{target}}/release/{{bin}} {{host}}/usr/bin/{{bin}} 13 14 incus exec {{host}} -- systemctl restart gordian-knot.service 14 15 15 16 resolve *ident: