atomcode_core/coding_plan/
crypto.rs1use thiserror::Error;
19
20pub trait RequestSigner: Send + Sync {
24 fn sign(&self, req: SignInput<'_>) -> Result<SignOutput, SignError>;
25 fn algorithm_version(&self) -> u8;
29}
30
31pub struct SignInput<'a> {
32 pub method: &'a str,
33 pub path: &'a str,
34 pub body: &'a [u8],
35 pub oauth_token: &'a str,
36 pub user_id: &'a str,
37 pub timestamp_unix: u64,
38 pub nonce: [u8; 16],
39}
40
41#[derive(Debug)]
42pub struct SignOutput {
43 pub headers: Vec<(&'static str, String)>,
44}
45
46#[derive(Debug, Error)]
47pub enum SignError {
48 #[error("signer unavailable in this build")]
49 Unavailable,
50 #[error("signing-key derivation failed: {0}")]
51 Derive(String),
52}
53
54pub struct UnavailableSigner;
56
57impl RequestSigner for UnavailableSigner {
58 fn sign(&self, _req: SignInput<'_>) -> Result<SignOutput, SignError> {
59 Err(SignError::Unavailable)
60 }
61 fn algorithm_version(&self) -> u8 {
62 0
63 }
64}
65
66#[cfg(not(feature = "codingplan-crypto"))]
67static UNAVAILABLE_SIGNER: UnavailableSigner = UnavailableSigner;
68
69#[cfg(not(feature = "codingplan-crypto"))]
74pub fn signer() -> &'static dyn RequestSigner {
75 &UNAVAILABLE_SIGNER
76}
77
78#[cfg(feature = "codingplan-crypto")]
79struct RealSigner;
80
81#[cfg(feature = "codingplan-crypto")]
82impl RequestSigner for RealSigner {
83 fn sign(&self, req: SignInput<'_>) -> Result<SignOutput, SignError> {
84 Ok(SignOutput {
90 headers: atomcode_codingplan_crypto::sign_v1(
91 req.method,
92 req.path,
93 req.body,
94 req.oauth_token,
95 req.user_id,
96 req.timestamp_unix,
97 &req.nonce,
98 env!("CARGO_PKG_VERSION"),
99 ),
100 })
101 }
102
103 fn algorithm_version(&self) -> u8 {
104 atomcode_codingplan_crypto::ALGORITHM_VERSION
105 }
106}
107
108#[cfg(feature = "codingplan-crypto")]
109static REAL_SIGNER: RealSigner = RealSigner;
110
111#[cfg(feature = "codingplan-crypto")]
112pub fn signer() -> &'static dyn RequestSigner {
113 &REAL_SIGNER
114}
115
116pub(crate) fn is_atomgit_gateway(base_url: &str) -> bool {
130 let url = match url::Url::parse(base_url) {
131 Ok(u) => u,
132 Err(_) => return false,
133 };
134 match url.scheme() {
135 "https" | "http" => {}
136 _ => return false,
137 }
138 matches!(
139 url.host_str(),
140 Some("llm-api.atomgit.com") | Some("api-ai.gitcode.com")
141 )
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn unavailable_signer_returns_unavailable_error() {
150 let s = UnavailableSigner;
151 let input = SignInput {
152 method: "POST",
153 path: "/v1/chat/completions",
154 body: b"{}",
155 oauth_token: "any-token",
156 user_id: "user-1",
157 timestamp_unix: 1_700_000_000,
158 nonce: [0u8; 16],
159 };
160 let err = s.sign(input).expect_err("UnavailableSigner must error");
161 assert!(matches!(err, SignError::Unavailable));
162 }
163
164 #[test]
165 fn unavailable_signer_reports_algorithm_version_zero() {
166 let s = UnavailableSigner;
167 assert_eq!(s.algorithm_version(), 0);
168 }
169
170 #[cfg(not(feature = "codingplan-crypto"))]
171 #[test]
172 fn default_signer_in_open_source_build_is_unavailable() {
173 let input = SignInput {
174 method: "POST",
175 path: "/v1/chat/completions",
176 body: b"{}",
177 oauth_token: "any-token",
178 user_id: "user-1",
179 timestamp_unix: 1_700_000_000,
180 nonce: [0u8; 16],
181 };
182 let err = signer().sign(input).expect_err("open-source must error");
183 assert!(matches!(err, SignError::Unavailable));
184 }
185
186 #[test]
187 fn is_atomgit_gateway_matches_official_host() {
188 assert!(is_atomgit_gateway("https://llm-api.atomgit.com/v1"));
189 assert!(is_atomgit_gateway("https://llm-api.atomgit.com/v1/chat/completions"));
190 }
191
192 #[test]
193 fn is_atomgit_gateway_matches_legacy_codingplan_host() {
194 assert!(is_atomgit_gateway("https://api-ai.gitcode.com/v1"));
199 assert!(is_atomgit_gateway("https://api-ai.gitcode.com/v1/chat/completions"));
200 }
201
202 #[test]
203 fn is_atomgit_gateway_rejects_third_party_hosts() {
204 assert!(!is_atomgit_gateway("https://api.anthropic.com"));
205 assert!(!is_atomgit_gateway("https://api.openai.com/v1"));
206 assert!(!is_atomgit_gateway("http://localhost:11434"));
207 }
208
209 #[test]
210 fn is_atomgit_gateway_rejects_subdomains_and_lookalikes() {
211 assert!(!is_atomgit_gateway("https://llm-api.atomgit.com.evil.example"));
212 assert!(!is_atomgit_gateway("https://evil.llm-api.atomgit.com"));
213 assert!(!is_atomgit_gateway("https://atomgit.com"));
214 }
215
216 #[test]
217 fn is_atomgit_gateway_rejects_malformed_input() {
218 assert!(!is_atomgit_gateway(""));
219 assert!(!is_atomgit_gateway("not a url"));
220 assert!(!is_atomgit_gateway("ftp://llm-api.atomgit.com"));
221 }
222}