1use std::env;
2use std::fs::{self, OpenOptions};
3use std::io::{self, Write};
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt;
6use std::path::{Path, PathBuf};
7use std::thread;
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use reqwest::blocking::Client;
11use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
12use ring::digest::{Context, SHA256};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use time::format_description::well_known::Rfc3339;
16use time::OffsetDateTime;
17
18use crate::auth::jwt;
19use crate::error::ViaError;
20use crate::redaction::Redactor;
21use crate::secrets::SecretValue;
22
23const CACHE_EXPIRY_SKEW_SECONDS: i64 = 60;
24const CACHE_LOCK_WAIT: Duration = Duration::from_secs(10);
25const CACHE_LOCK_POLL: Duration = Duration::from_millis(50);
26const CACHE_LOCK_STALE_AFTER: Duration = Duration::from_secs(60);
27
28pub fn installation_access_token(
29 client: &Client,
30 api_base_url: &str,
31 credential: &SecretValue,
32 private_key: Option<&SecretValue>,
33 redactor: &mut Redactor,
34) -> Result<String, ViaError> {
35 redactor.add(credential.expose());
36 if let Some(private_key) = private_key {
37 redactor.add(private_key.expose());
38 }
39
40 let bundle =
41 CredentialBundle::parse(credential.expose(), private_key.map(SecretValue::expose))?;
42 bundle.validate_kind()?;
43
44 if let Some(cache_dir) = default_cache_dir() {
45 return installation_access_token_with_cache_dir(
46 client,
47 api_base_url,
48 &bundle,
49 redactor,
50 &cache_dir,
51 );
52 }
53
54 crate::timing::event("github_app token cache", "disabled");
55 exchange_installation_access_token(client, api_base_url, &bundle, redactor)
56 .map(|token| token.token)
57}
58
59fn installation_access_token_with_cache_dir(
60 client: &Client,
61 api_base_url: &str,
62 bundle: &CredentialBundle,
63 redactor: &mut Redactor,
64 cache_dir: &Path,
65) -> Result<String, ViaError> {
66 let now = unix_timestamp()?;
67 let key = cache_key(api_base_url, bundle);
68 let cache_path = token_cache_path(cache_dir, &key);
69
70 let cache_span = crate::timing::span("github_app token cache read");
71 if let Some(token) = read_cached_token(&cache_path, now) {
72 cache_span.finish("hit");
73 redactor.add(&token);
74 return Ok(token);
75 }
76 cache_span.finish("miss");
77
78 let lock_path = token_lock_path(cache_dir, &key);
79 let lock_span = crate::timing::span("github_app token cache lock");
80 if let Some(_lock) = CacheLock::acquire(&lock_path) {
81 lock_span.finish("acquired");
82 let now = unix_timestamp()?;
83 let cache_span = crate::timing::span("github_app token cache read_after_lock");
84 if let Some(token) = read_cached_token(&cache_path, now) {
85 cache_span.finish("hit");
86 redactor.add(&token);
87 return Ok(token);
88 }
89 cache_span.finish("miss");
90
91 let token = exchange_installation_access_token(client, api_base_url, bundle, redactor)?;
92 let write_span = crate::timing::span("github_app token cache write");
93 match write_cached_token(
94 &cache_path,
95 &CachedInstallationToken {
96 token: token.token.clone(),
97 expires_at: token.expires_at,
98 },
99 ) {
100 Ok(()) => write_span.finish("ok"),
101 Err(_) => write_span.finish("failed"),
102 };
103 return Ok(token.token);
104 }
105
106 lock_span.finish("unavailable");
107 exchange_installation_access_token(client, api_base_url, bundle, redactor)
108 .map(|token| token.token)
109}
110
111fn exchange_installation_access_token(
112 client: &Client,
113 api_base_url: &str,
114 bundle: &CredentialBundle,
115 redactor: &mut Redactor,
116) -> Result<InstallationAccessToken, ViaError> {
117 redactor.add(&bundle.private_key);
118 let jwt_span = crate::timing::span("github_app jwt sign");
119 let jwt = app_jwt(bundle)?;
120 jwt_span.finish("ok");
121 redactor.add(&jwt);
122
123 let url = format!(
124 "{}/app/installations/{}/access_tokens",
125 api_base_url.trim_end_matches('/'),
126 bundle.installation_id
127 );
128 let exchange_span = crate::timing::span("github_app installation token exchange");
129 let response = match client
130 .post(url)
131 .headers(token_exchange_headers(&jwt)?)
132 .send()
133 {
134 Ok(response) => {
135 let status = response.status();
136 exchange_span.finish(format!("status={status}"));
137 response
138 }
139 Err(error) => {
140 exchange_span.finish("failed");
141 return Err(error.into());
142 }
143 };
144 let status = response.status();
145 let body_span = crate::timing::span("github_app installation token body");
146 let body = match response.text() {
147 Ok(body) => {
148 body_span.finish(format!("bytes={}", body.len()));
149 body
150 }
151 Err(error) => {
152 body_span.finish("failed");
153 return Err(error.into());
154 }
155 };
156
157 if !status.is_success() {
158 let body = redactor.redact(&body);
159 return Err(ViaError::InvalidArgument(format!(
160 "GitHub App token exchange failed with status {status}: {body}"
161 )));
162 }
163
164 let response: InstallationTokenResponse = serde_json::from_str(&body)?;
165 let expires_at = parse_github_expires_at(&response.expires_at)?;
166 redactor.add(&response.token);
167 Ok(InstallationAccessToken {
168 token: response.token,
169 expires_at,
170 })
171}
172
173pub fn validate_credential_bundle(raw: &str, private_key: Option<&str>) -> Result<(), ViaError> {
174 let bundle = CredentialBundle::parse(raw, private_key)?;
175 bundle.validate_kind()?;
176 app_jwt(&bundle)?;
177 Ok(())
178}
179
180fn app_jwt(bundle: &CredentialBundle) -> Result<String, ViaError> {
181 let now = unix_timestamp()?;
182 let claims = serde_json::json!({
183 "iat": now - 60,
184 "exp": now + 540,
185 "iss": bundle.issuer,
186 });
187 jwt::sign_rs256(&claims, &bundle.private_key)
188}
189
190fn token_exchange_headers(jwt: &str) -> Result<HeaderMap, ViaError> {
191 let mut headers = HeaderMap::new();
192 headers.insert(
193 ACCEPT,
194 HeaderValue::from_static("application/vnd.github+json"),
195 );
196 headers.insert(USER_AGENT, HeaderValue::from_static("via-cli"));
197 headers.insert(
198 "X-GitHub-Api-Version",
199 HeaderValue::from_static("2022-11-28"),
200 );
201 headers.insert(
202 AUTHORIZATION,
203 HeaderValue::from_str(&format!("Bearer {jwt}"))
204 .map_err(|_| ViaError::InvalidConfig("invalid GitHub App JWT".to_owned()))?,
205 );
206 Ok(headers)
207}
208
209fn unix_timestamp() -> Result<i64, ViaError> {
210 let duration = SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .map_err(|_| ViaError::InvalidConfig("system clock is before UNIX epoch".to_owned()))?;
213 i64::try_from(duration.as_secs())
214 .map_err(|_| ViaError::InvalidConfig("system clock timestamp is too large".to_owned()))
215}
216
217#[derive(Debug, PartialEq, Eq)]
218struct CredentialBundle {
219 kind: String,
220 issuer: String,
221 installation_id: String,
222 private_key: String,
223}
224
225impl CredentialBundle {
226 fn parse(raw: &str, private_key: Option<&str>) -> Result<Self, ViaError> {
227 let value: Value = serde_json::from_str(raw).map_err(credential_json_error)?;
228
229 Ok(Self {
230 kind: required_string(&value, "type")?,
231 issuer: required_app_id(&value)?,
232 installation_id: required_string_or_number(&value, "installation_id")?,
233 private_key: match private_key {
234 Some(private_key) => private_key.to_owned(),
235 None => required_string(&value, "private_key")?,
236 },
237 })
238 }
239
240 fn validate_kind(&self) -> Result<(), ViaError> {
241 if self.kind == "github_app" {
242 return Ok(());
243 }
244
245 Err(ViaError::InvalidConfig(
246 "GitHub App credential bundle must set type = \"github_app\"".to_owned(),
247 ))
248 }
249}
250
251#[derive(Debug, Deserialize)]
252struct InstallationTokenResponse {
253 token: String,
254 expires_at: String,
255}
256
257struct InstallationAccessToken {
258 token: String,
259 expires_at: i64,
260}
261
262#[derive(Debug, Deserialize, Serialize)]
263struct CachedInstallationToken {
264 token: String,
265 expires_at: i64,
266}
267
268struct CacheLock {
269 path: PathBuf,
270}
271
272impl CacheLock {
273 fn acquire(path: &Path) -> Option<Self> {
274 if let Some(parent) = path.parent() {
275 create_private_dir(parent).ok()?;
276 }
277
278 let started = Instant::now();
279 loop {
280 match OpenOptions::new().write(true).create_new(true).open(path) {
281 Ok(mut file) => {
282 let _ = set_private_file_permissions(path);
283 let _ = writeln!(file, "{}", std::process::id());
284 return Some(Self {
285 path: path.to_path_buf(),
286 });
287 }
288 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
289 if lock_is_stale(path) {
290 let _ = fs::remove_file(path);
291 continue;
292 }
293
294 if started.elapsed() >= CACHE_LOCK_WAIT {
295 return None;
296 }
297
298 thread::sleep(CACHE_LOCK_POLL);
299 }
300 Err(_) => return None,
301 }
302 }
303 }
304}
305
306impl Drop for CacheLock {
307 fn drop(&mut self) {
308 let _ = fs::remove_file(&self.path);
309 }
310}
311
312fn default_cache_dir() -> Option<PathBuf> {
313 env_path("VIA_CACHE_DIR")
314 .or_else(|| env_path("XDG_CACHE_HOME").map(|path| path.join("via")))
315 .or_else(|| env_path("HOME").map(|path| path.join(".cache").join("via")))
316}
317
318fn env_path(name: &str) -> Option<PathBuf> {
319 env::var_os(name)
320 .filter(|value| !value.as_os_str().is_empty())
321 .map(PathBuf::from)
322}
323
324fn token_cache_path(cache_dir: &Path, key: &str) -> PathBuf {
325 cache_dir.join("github-app").join(format!("{key}.json"))
326}
327
328fn token_lock_path(cache_dir: &Path, key: &str) -> PathBuf {
329 cache_dir.join("github-app").join(format!("{key}.lock"))
330}
331
332fn cache_key(api_base_url: &str, bundle: &CredentialBundle) -> String {
333 let mut context = Context::new(&SHA256);
334 context.update(api_base_url.trim_end_matches('/').as_bytes());
335 context.update(b"\0");
336 context.update(bundle.issuer.as_bytes());
337 context.update(b"\0");
338 context.update(bundle.installation_id.as_bytes());
339 hex_encode(context.finish().as_ref())
340}
341
342fn read_cached_token(path: &Path, now: i64) -> Option<String> {
343 let raw = fs::read_to_string(path).ok()?;
344 let cached: CachedInstallationToken = serde_json::from_str(&raw).ok()?;
345 if cached.expires_at <= now + CACHE_EXPIRY_SKEW_SECONDS {
346 return None;
347 }
348 Some(cached.token)
349}
350
351fn write_cached_token(path: &Path, token: &CachedInstallationToken) -> Result<(), ViaError> {
352 let parent = path
353 .parent()
354 .ok_or_else(|| ViaError::InvalidConfig("cache path has no parent".to_owned()))?;
355 create_private_dir(parent)?;
356
357 let temp_path = path.with_file_name(format!(
358 ".{}.{}.tmp",
359 path.file_name()
360 .and_then(|name| name.to_str())
361 .unwrap_or("token"),
362 std::process::id()
363 ));
364 let raw = serde_json::to_vec(token)?;
365 {
366 let mut file = OpenOptions::new()
367 .write(true)
368 .create(true)
369 .truncate(true)
370 .open(&temp_path)?;
371 let _ = set_private_file_permissions(&temp_path);
372 file.write_all(&raw)?;
373 file.sync_all()?;
374 }
375
376 match fs::rename(&temp_path, path) {
377 Ok(()) => Ok(()),
378 Err(error) => {
379 if error.kind() == io::ErrorKind::AlreadyExists {
380 fs::remove_file(path)?;
381 fs::rename(&temp_path, path)?;
382 Ok(())
383 } else {
384 let _ = fs::remove_file(&temp_path);
385 Err(error.into())
386 }
387 }
388 }
389}
390
391fn create_private_dir(path: &Path) -> io::Result<()> {
392 fs::create_dir_all(path)?;
393 set_private_dir_permissions(path)
394}
395
396#[cfg(unix)]
397fn set_private_dir_permissions(path: &Path) -> io::Result<()> {
398 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
399}
400
401#[cfg(not(unix))]
402fn set_private_dir_permissions(_path: &Path) -> io::Result<()> {
403 Ok(())
404}
405
406#[cfg(unix)]
407fn set_private_file_permissions(path: &Path) -> io::Result<()> {
408 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
409}
410
411#[cfg(not(unix))]
412fn set_private_file_permissions(_path: &Path) -> io::Result<()> {
413 Ok(())
414}
415
416fn lock_is_stale(path: &Path) -> bool {
417 path.metadata()
418 .and_then(|metadata| metadata.modified())
419 .and_then(|modified| modified.elapsed().map_err(io::Error::other))
420 .is_ok_and(|age| age >= CACHE_LOCK_STALE_AFTER)
421}
422
423fn hex_encode(bytes: &[u8]) -> String {
424 const HEX: &[u8; 16] = b"0123456789abcdef";
425 let mut encoded = String::with_capacity(bytes.len() * 2);
426 for byte in bytes {
427 encoded.push(HEX[(byte >> 4) as usize] as char);
428 encoded.push(HEX[(byte & 0x0f) as usize] as char);
429 }
430 encoded
431}
432
433fn parse_github_expires_at(value: &str) -> Result<i64, ViaError> {
434 OffsetDateTime::parse(value, &Rfc3339)
435 .map(OffsetDateTime::unix_timestamp)
436 .map_err(|error| {
437 ViaError::InvalidArgument(format!(
438 "GitHub App token response had invalid `expires_at` `{value}`: {error}"
439 ))
440 })
441}
442
443fn credential_json_error(error: serde_json::Error) -> ViaError {
444 let mut message = format!("GitHub App credential bundle must be valid JSON: {error}");
445 if error.to_string().contains("control character") {
446 message.push_str(
447 "; private_key must escape PEM newlines as `\\n`, not contain raw line breaks inside the JSON string",
448 );
449 }
450 ViaError::InvalidConfig(message)
451}
452
453fn required_string(value: &Value, field: &str) -> Result<String, ViaError> {
454 value
455 .get(field)
456 .and_then(Value::as_str)
457 .filter(|value| !value.trim().is_empty())
458 .map(str::to_owned)
459 .ok_or_else(|| {
460 ViaError::InvalidConfig(format!(
461 "GitHub App credential bundle must include non-empty `{field}`"
462 ))
463 })
464}
465
466fn required_app_id(value: &Value) -> Result<String, ViaError> {
467 if let Some(number) = value.get("app_id").and_then(Value::as_u64) {
468 return Ok(number.to_string());
469 }
470 if let Some(app_id) = value
471 .get("app_id")
472 .and_then(Value::as_str)
473 .filter(|value| value.chars().all(|character| character.is_ascii_digit()))
474 {
475 return Ok(app_id.to_owned());
476 }
477
478 if value.get("client_id").is_some() {
479 return Err(ViaError::InvalidConfig(
480 "GitHub App credential bundle must include numeric `app_id`; `client_id` is metadata only and is not used for this token exchange".to_owned(),
481 ));
482 }
483
484 Err(ViaError::InvalidConfig(
485 "GitHub App credential bundle must include numeric `app_id`".to_owned(),
486 ))
487}
488
489fn required_string_or_number(value: &Value, field: &str) -> Result<String, ViaError> {
490 if let Some(value) = value
491 .get(field)
492 .and_then(Value::as_str)
493 .filter(|value| !value.trim().is_empty())
494 {
495 return Ok(value.to_owned());
496 }
497 if let Some(number) = value.get(field).and_then(Value::as_u64) {
498 return Ok(number.to_string());
499 }
500
501 Err(ViaError::InvalidConfig(format!(
502 "GitHub App credential bundle must include non-empty `{field}`"
503 )))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use std::io::Read;
510 use std::net::TcpListener;
511
512 const PRIVATE_KEY: &str = include_str!("../../tests/fixtures/rsa-private-key.pkcs1.pem");
513
514 #[test]
515 fn parses_bundle_with_app_id_string() {
516 let bundle = CredentialBundle::parse(
517 &serde_json::json!({
518 "type": "github_app",
519 "app_id": "42",
520 "installation_id": "123",
521 "private_key": PRIVATE_KEY,
522 })
523 .to_string(),
524 None,
525 )
526 .unwrap();
527
528 assert_eq!(
529 bundle,
530 CredentialBundle {
531 kind: "github_app".to_owned(),
532 issuer: "42".to_owned(),
533 installation_id: "123".to_owned(),
534 private_key: PRIVATE_KEY.to_owned(),
535 }
536 );
537 }
538
539 #[test]
540 fn parses_numeric_app_and_installation_ids() {
541 let bundle = CredentialBundle::parse(
542 &serde_json::json!({
543 "type": "github_app",
544 "app_id": 42,
545 "installation_id": 123,
546 "private_key": PRIVATE_KEY,
547 })
548 .to_string(),
549 None,
550 )
551 .unwrap();
552
553 assert_eq!(bundle.issuer, "42");
554 assert_eq!(bundle.installation_id, "123");
555 }
556
557 #[test]
558 fn rejects_missing_private_key() {
559 let error = CredentialBundle::parse(
560 &serde_json::json!({
561 "type": "github_app",
562 "app_id": 42,
563 "installation_id": "123",
564 })
565 .to_string(),
566 None,
567 )
568 .unwrap_err();
569
570 assert!(
571 matches!(error, ViaError::InvalidConfig(message) if message.contains("private_key"))
572 );
573 }
574
575 #[test]
576 fn explains_raw_newlines_inside_private_key_json() {
577 let error = CredentialBundle::parse(
578 r#"{
579 "type": "github_app",
580 "app_id": 42,
581 "installation_id": "123",
582 "private_key": "-----BEGIN RSA PRIVATE KEY-----
583abc
584-----END RSA PRIVATE KEY-----"
585}"#,
586 None,
587 )
588 .unwrap_err();
589
590 assert!(
591 matches!(error, ViaError::InvalidConfig(message) if message.contains("escape PEM newlines"))
592 );
593 }
594
595 #[test]
596 fn validates_bundle_and_private_key() {
597 validate_credential_bundle(
598 &serde_json::json!({
599 "type": "github_app",
600 "app_id": 42,
601 "installation_id": "123",
602 "private_key": PRIVATE_KEY,
603 })
604 .to_string(),
605 None,
606 )
607 .unwrap();
608 }
609
610 #[test]
611 fn validates_split_metadata_and_private_key() {
612 validate_credential_bundle(
613 &serde_json::json!({
614 "type": "github_app",
615 "app_id": 42,
616 "installation_id": "123",
617 })
618 .to_string(),
619 Some(PRIVATE_KEY),
620 )
621 .unwrap();
622 }
623
624 #[test]
625 fn creates_app_jwt() {
626 let bundle = CredentialBundle {
627 kind: "github_app".to_owned(),
628 issuer: "42".to_owned(),
629 installation_id: "123".to_owned(),
630 private_key: PRIVATE_KEY.to_owned(),
631 };
632
633 let token = app_jwt(&bundle).unwrap();
634
635 assert_eq!(token.split('.').count(), 3);
636 }
637
638 #[test]
639 fn rejects_client_id_without_app_id() {
640 let error = CredentialBundle::parse(
641 &serde_json::json!({
642 "type": "github_app",
643 "client_id": "Iv1.client",
644 "installation_id": "123",
645 "private_key": PRIVATE_KEY,
646 })
647 .to_string(),
648 None,
649 )
650 .unwrap_err();
651
652 assert!(matches!(
653 error,
654 ViaError::InvalidConfig(message)
655 if message.contains("numeric `app_id`") && message.contains("client_id")
656 ));
657 }
658
659 #[test]
660 fn parses_github_token_expiry() {
661 assert_eq!(parse_github_expires_at("1970-01-01T00:00:00Z").unwrap(), 0);
662 assert_eq!(
663 parse_github_expires_at("2026-05-02T12:34:56Z").unwrap(),
664 1_777_725_296
665 );
666 assert_eq!(
667 parse_github_expires_at("2026-05-02T12:34:56.789Z").unwrap(),
668 1_777_725_296
669 );
670 assert_eq!(
671 parse_github_expires_at("2026-05-02T08:34:56-04:00").unwrap(),
672 1_777_725_296
673 );
674 }
675
676 #[test]
677 fn rejects_invalid_github_token_expiry() {
678 assert!(parse_github_expires_at("2026-02-29T12:34:56Z").is_err());
679 assert!(parse_github_expires_at("not-a-date").is_err());
680 }
681
682 #[test]
683 fn returns_unexpired_cached_installation_token() {
684 let cache_dir = temp_cache_dir("hit");
685 let bundle = test_bundle();
686 let key = cache_key("https://api.github.com", &bundle);
687 let cache_path = token_cache_path(&cache_dir, &key);
688 let now = unix_timestamp().unwrap();
689 write_cached_token(
690 &cache_path,
691 &CachedInstallationToken {
692 token: "cached-token".to_owned(),
693 expires_at: now + 3_600,
694 },
695 )
696 .unwrap();
697
698 let client = Client::new();
699 let mut redactor = Redactor::new();
700 let token = installation_access_token_with_cache_dir(
701 &client,
702 "https://api.github.com",
703 &bundle,
704 &mut redactor,
705 &cache_dir,
706 )
707 .unwrap();
708
709 assert_eq!(token, "cached-token");
710 assert_eq!(redactor.redact("cached-token"), "[REDACTED]");
711 let _ = fs::remove_dir_all(cache_dir);
712 }
713
714 #[test]
715 fn exchanges_and_caches_expired_installation_token() {
716 crate::tls::install_crypto_provider();
717
718 let cache_dir = temp_cache_dir("refresh");
719 let bundle = test_bundle();
720
721 let response_body = serde_json::json!({
722 "token": "fresh-token",
723 "expires_at": "2099-01-01T00:00:00Z",
724 })
725 .to_string();
726 let (api_base_url, server) = token_server(response_body);
727 let key = cache_key(&api_base_url, &bundle);
728 let cache_path = token_cache_path(&cache_dir, &key);
729 write_cached_token(
730 &cache_path,
731 &CachedInstallationToken {
732 token: "expired-token".to_owned(),
733 expires_at: 0,
734 },
735 )
736 .unwrap();
737
738 let client = Client::new();
739 let mut redactor = Redactor::new();
740 let token = installation_access_token_with_cache_dir(
741 &client,
742 &api_base_url,
743 &bundle,
744 &mut redactor,
745 &cache_dir,
746 )
747 .unwrap();
748 let request = server.join().unwrap();
749
750 assert_eq!(token, "fresh-token");
751 assert!(request.starts_with("POST /app/installations/123/access_tokens "));
752 assert_eq!(
753 read_cached_token(&cache_path, unix_timestamp().unwrap()).as_deref(),
754 Some("fresh-token")
755 );
756 let _ = fs::remove_dir_all(cache_dir);
757 }
758
759 fn test_bundle() -> CredentialBundle {
760 CredentialBundle {
761 kind: "github_app".to_owned(),
762 issuer: "42".to_owned(),
763 installation_id: "123".to_owned(),
764 private_key: PRIVATE_KEY.to_owned(),
765 }
766 }
767
768 fn temp_cache_dir(name: &str) -> PathBuf {
769 let mut path = env::temp_dir();
770 path.push(format!(
771 "via-github-app-cache-test-{name}-{}-{}",
772 std::process::id(),
773 unix_timestamp().unwrap()
774 ));
775 let _ = fs::remove_dir_all(&path);
776 path
777 }
778
779 fn token_server(response_body: String) -> (String, thread::JoinHandle<String>) {
780 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
781 let address = listener.local_addr().unwrap();
782 let handle = thread::spawn(move || {
783 let (mut stream, _) = listener.accept().unwrap();
784 let mut buffer = [0_u8; 8192];
785 let read = stream.read(&mut buffer).unwrap();
786 let request = String::from_utf8_lossy(&buffer[..read]).to_string();
787 let response = format!(
788 "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
789 response_body.len(),
790 response_body
791 );
792 stream.write_all(response.as_bytes()).unwrap();
793 request
794 });
795
796 (format!("http://{address}"), handle)
797 }
798}