1use std::io::Read;
20use std::path::{Component, Path, PathBuf};
21
22use serde_json::json;
23use sha2::{Digest, Sha256};
24
25use super::commands::{
26 Refusal, admit, read_config, signing_key, write_private, write_replacing, yank_line,
27};
28use super::protocol::Config;
29
30const MAX_UPLOAD: u64 = 64 * 1024 * 1024;
32
33pub const DEFAULT_LISTEN: &str = "127.0.0.1:8730";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38struct TokenEntry {
39 sha256: String,
40 organisations: Vec<String>,
41 can: Vec<String>,
42}
43
44pub fn serve(
48 dir: &Path,
49 key_file: &Path,
50 tokens_file: &Path,
51 listen: &str,
52 ready: impl FnOnce(&str),
53) -> Result<(), String> {
54 for (what, path) in [("signing key", key_file), ("tokens file", tokens_file)] {
55 if inside(path, dir) {
56 return Err(format!(
57 "the {what} `{}` is inside the registry directory, which is served — keep it elsewhere",
58 path.display()
59 ));
60 }
61 }
62 let mut config = read_config(dir)?;
63 let key = signing_key(key_file)?;
64 super::commands::current_key(&config, &key, key_file)?;
65 if config.api.is_none() {
66 config.api = Some("api".to_string());
67 write_replacing(&dir.join("config.json"), config.render().as_bytes())
68 .map_err(|e| format!("could not record the API in `config.json`: {e}"))?;
69 }
70 let tokens = read_tokens(tokens_file)?;
71 let server = tiny_http::Server::http(listen)
72 .map_err(|e| format!("could not listen on `{listen}`: {e}"))?;
73 let bound = server
74 .server_addr()
75 .to_ip()
76 .ok_or("the server isn't on an IP address")?;
77 if !bound.ip().is_loopback() {
78 eprintln!(
79 "bynk: warning: serving on {bound}, beyond this machine, over plain HTTP — tokens travel in clear unless a TLS-terminating proxy is in front"
80 );
81 }
82 ready(&format!("http://{bound}"));
83 for mut request in server.incoming_requests() {
84 let (status, body, kind) = handle(dir, &config, &key, &tokens, &mut request);
85 let response = tiny_http::Response::from_data(body)
86 .with_status_code(status)
87 .with_header(
88 tiny_http::Header::from_bytes(&b"Content-Type"[..], kind.as_bytes())
89 .expect("a valid header"),
90 );
91 let _ = request.respond(response);
92 }
93 Ok(())
94}
95
96fn handle(
98 dir: &Path,
99 config: &Config,
100 key: &super::protocol::SigningKey,
101 tokens: &[TokenEntry],
102 request: &mut tiny_http::Request,
103) -> (u16, Vec<u8>, &'static str) {
104 let url = request
105 .url()
106 .split(['?', '#'])
107 .next()
108 .unwrap_or("/")
109 .to_string();
110 let method = request.method().clone();
111 if let Some(rest) = url.strip_prefix("/api/") {
112 let parts: Vec<&str> = rest.split('/').collect();
113 let error = |status: u16, code: &str, message: String| {
114 (
115 status,
116 json!({ "error": code, "message": message })
117 .to_string()
118 .into_bytes(),
119 "application/json",
120 )
121 };
122 let token = request
123 .headers()
124 .iter()
125 .find(|h| h.field.equiv("Authorization"))
126 .and_then(|h| h.value.as_str().strip_prefix("Bearer ").map(str::to_owned));
127 let authorise = |org: &str, cap: &str| -> Result<(), (u16, Vec<u8>, &'static str)> {
128 let Some(token) = &token else {
129 return Err(error(
130 401,
131 "unauthorised",
132 "this registry needs a publish token".into(),
133 ));
134 };
135 let digest = hex(&Sha256::digest(token.as_bytes()));
136 let Some(entry) = tokens
137 .iter()
138 .find(|t| constant_time_eq(t.sha256.as_bytes(), digest.as_bytes()))
139 else {
140 return Err(error(
141 401,
142 "unauthorised",
143 "the token isn't known to this registry".into(),
144 ));
145 };
146 if !entry.organisations.iter().any(|o| o == org) {
147 return Err(error(
148 403,
149 "forbidden",
150 format!("the token can't act for the organisation `{org}`"),
151 ));
152 }
153 if !entry.can.iter().any(|c| c == cap) {
154 return Err(error(403, "forbidden", format!("the token can't `{cap}`")));
155 }
156 Ok(())
157 };
158 return match (method, parts.as_slice()) {
159 (tiny_http::Method::Put, [org, package, version]) => {
160 if let Err(e) = authorise(org, "publish") {
161 return e;
162 }
163 if request.body_length().is_some_and(|n| n as u64 > MAX_UPLOAD) {
164 return error(
165 413,
166 "too_large",
167 format!("an upload is limited to {MAX_UPLOAD} bytes"),
168 );
169 }
170 let mut upload = Vec::new();
171 if request
172 .as_reader()
173 .take(MAX_UPLOAD + 1)
174 .read_to_end(&mut upload)
175 .is_err()
176 {
177 return error(400, "invalid_package", "the upload couldn't be read".into());
178 }
179 if upload.len() as u64 > MAX_UPLOAD {
180 return error(
181 413,
182 "too_large",
183 format!("an upload is limited to {MAX_UPLOAD} bytes"),
184 );
185 }
186 let name = format!("{org}/{package}");
187 match admit(dir, config, key, &upload, Some((&name, version))) {
188 Ok(a) => (
191 if a.fresh { 201 } else { 200 },
192 a.line.render().into_bytes(),
193 "application/json",
194 ),
195 Err(r) => refusal(r),
196 }
197 }
198 (tiny_http::Method::Post, [org, package, version, verb @ ("yank" | "unyank")]) => {
199 if let Err(e) = authorise(org, "yank") {
200 return e;
201 }
202 match yank_line(dir, &format!("{org}/{package}"), version, *verb == "unyank") {
203 Ok(()) => (
204 200,
205 json!({ "ok": true }).to_string().into_bytes(),
206 "application/json",
207 ),
208 Err(r) if r.code == "withdrawn" => {
211 let (_, body, kind) = refusal(r);
212 (409, body, kind)
213 }
214 Err(r) => refusal(r),
215 }
216 }
217 _ => error(404, "not_found", format!("no API endpoint at `{url}`")),
218 };
219 }
220 if method != tiny_http::Method::Get {
221 return (405, Vec::new(), "text/plain");
222 }
223 match served_path(dir, &url).and_then(|p| std::fs::read(p).ok()) {
224 Some(bytes) => (200, bytes, "application/octet-stream"),
225 None => (404, Vec::new(), "text/plain"),
226 }
227}
228
229fn refusal(r: Refusal) -> (u16, Vec<u8>, &'static str) {
231 let status = match r.code {
232 "invalid_package" => 400,
233 "version_exists" => 409,
234 "withdrawn" => 410,
235 "not_found" => 404,
236 "too_large" => 413,
237 _ => 500,
238 };
239 (
240 status,
241 json!({ "error": r.code, "message": r.message })
242 .to_string()
243 .into_bytes(),
244 "application/json",
245 )
246}
247
248fn served_path(dir: &Path, url: &str) -> Option<PathBuf> {
250 let rel = url.trim_start_matches('/');
251 if rel.is_empty() || rel.contains('\\') {
252 return None;
253 }
254 let path = Path::new(rel);
255 if !path.components().all(|c| matches!(c, Component::Normal(_))) {
256 return None;
257 }
258 let full = dir.join(path);
259 full.is_file().then_some(full)
260}
261
262pub fn mint(
266 tokens_file: &Path,
267 organisations: &[String],
268 can: &[String],
269) -> Result<String, String> {
270 if organisations.is_empty() {
271 return Err("name at least one organisation with `--org`".to_string());
272 }
273 for org in organisations {
274 if let Some(problem) = bynk_project::manifest::org_problem(org) {
275 return Err(format!("`{org}` isn't an organisation: {problem}"));
276 }
277 }
278 for c in can {
279 if c != "publish" && c != "yank" {
280 return Err(format!("`{c}` isn't a capability: use `publish` or `yank`"));
281 }
282 }
283 let mut raw = [0u8; 32];
284 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut raw)
285 .map_err(|_| "could not generate a token".to_string())?;
286 let token = format!("bynk_{}", hex(&raw));
287 let mut text = std::fs::read_to_string(tokens_file).unwrap_or_default();
288 if !text.is_empty() && !text.ends_with('\n') {
289 text.push('\n');
290 }
291 let list = |xs: &[String]| {
292 xs.iter()
293 .map(|x| format!("\"{x}\""))
294 .collect::<Vec<_>>()
295 .join(", ")
296 };
297 text.push_str(&format!(
298 "\n[[token]]\nsha256 = \"{}\"\norganisations = [{}]\ncan = [{}]\n",
299 hex(&Sha256::digest(token.as_bytes())),
300 list(organisations),
301 list(can),
302 ));
303 write_private(tokens_file, text.as_bytes())?;
304 Ok(token)
305}
306
307fn read_tokens(path: &Path) -> Result<Vec<TokenEntry>, String> {
308 let text = std::fs::read_to_string(path)
309 .map_err(|e| format!("could not read the tokens file `{}`: {e}", path.display()))?;
310 let doc: toml::Table = text
311 .parse()
312 .map_err(|e| format!("the tokens file `{}` isn't TOML: {e}", path.display()))?;
313 let list = |v: Option<&toml::Value>| -> Vec<String> {
314 v.and_then(|v| v.as_array())
315 .map(|a| {
316 a.iter()
317 .filter_map(|x| x.as_str().map(str::to_owned))
318 .collect()
319 })
320 .unwrap_or_default()
321 };
322 let entries = doc
323 .get("token")
324 .and_then(|v| v.as_array())
325 .map(|a| {
326 a.iter()
327 .filter_map(|t| {
328 Some(TokenEntry {
329 sha256: t.get("sha256")?.as_str()?.to_ascii_lowercase(),
330 organisations: list(t.get("organisations")),
331 can: list(t.get("can")),
332 })
333 })
334 .collect()
335 })
336 .unwrap_or_default();
337 Ok(entries)
338}
339
340fn inside(path: &Path, dir: &Path) -> bool {
341 let abs = |p: &Path| {
342 p.canonicalize()
343 .unwrap_or_else(|_| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()))
344 };
345 abs(path).starts_with(abs(dir))
346}
347
348fn hex(bytes: &[u8]) -> String {
349 bytes.iter().map(|b| format!("{b:02x}")).collect()
350}
351
352fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
354 a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
355}