1use anyhow::{Context, Result, bail};
2use sha2::{Digest, Sha256};
3use std::path::{Component, Path, PathBuf};
4use std::time::Duration;
5use tokio::fs;
6use tokio::io::{AsyncReadExt, AsyncWriteExt};
7use tokio::net::{TcpListener, TcpStream};
8use tokio::process::Command;
9
10use crate::config::Config;
11
12const DEFAULT_HOST: &str = "127.0.0.1";
13const LAUNCHD_LABEL: &str = "top.biulight.shine.http";
14const MAX_HEADER_BYTES: usize = 8192;
15const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
18
19pub async fn handle_install(config: &Config, port: u16) -> Result<()> {
30 ensure_macos_service_support()?;
31
32 let root = http_root(config);
33 fs::create_dir_all(&root)
34 .await
35 .with_context(|| format!("creating {}", root.display()))?;
36
37 let plist_path = launchd_plist_path(config);
38 let parent = plist_path
39 .parent()
40 .context("launchd plist path must have a parent directory")?;
41 fs::create_dir_all(parent)
42 .await
43 .with_context(|| format!("creating {}", parent.display()))?;
44
45 let log_dir = launchd_log_dir(config);
46 fs::create_dir_all(&log_dir)
47 .await
48 .with_context(|| format!("creating {}", log_dir.display()))?;
49
50 let executable = service_executable(config)?;
51 let plist = launchd_plist(&executable, port, &log_dir);
52 fs::write(&plist_path, plist)
53 .await
54 .with_context(|| format!("writing {}", plist_path.display()))?;
55
56 let plist_arg = plist_path.to_string_lossy().to_string();
57 let _ = launchctl(&["unload", "-w", &plist_arg]).await;
58 launchctl(&["load", "-w", &plist_arg]).await?;
59
60 println!("Installed {LAUNCHD_LABEL}");
61 println!("Serving {}", root.display());
62 println!("URL base: http://{DEFAULT_HOST}:{port}/");
63 Ok(())
64}
65
66pub async fn handle_start(config: &Config, port: u16) -> Result<()> {
67 let root = http_root(config);
68 fs::create_dir_all(&root)
69 .await
70 .with_context(|| format!("creating {}", root.display()))?;
71
72 let listener = TcpListener::bind((DEFAULT_HOST, port))
73 .await
74 .with_context(|| format!("binding {DEFAULT_HOST}:{port}"))?;
75 println!("Serving {}", root.display());
76 println!("URL base: http://{DEFAULT_HOST}:{port}/");
77
78 loop {
79 let (stream, _) = listener.accept().await?;
80 let root = root.clone();
81 tokio::spawn(async move {
82 match tokio::time::timeout(CONNECTION_TIMEOUT, handle_connection(stream, root)).await {
83 Ok(Ok(())) => {}
84 Ok(Err(e)) => eprintln!("shine serve: connection error: {e:#}"),
85 Err(_) => {
86 eprintln!("shine serve: connection timed out after {CONNECTION_TIMEOUT:?}")
87 }
88 }
89 });
90 }
91}
92
93pub async fn handle_status(config: &Config) -> Result<()> {
94 ensure_macos_service_support()?;
95
96 let plist_path = launchd_plist_path(config);
97 if !plist_path.exists() {
98 println!("Not installed");
99 return Ok(());
100 }
101
102 match launchctl(&["list", LAUNCHD_LABEL]).await {
103 Ok(()) => println!("Installed and loaded"),
104 Err(_) => println!("Installed but not loaded"),
105 }
106 println!("Plist: {}", plist_path.display());
107 Ok(())
108}
109
110pub async fn handle_uninstall(config: &Config) -> Result<()> {
111 ensure_macos_service_support()?;
112
113 let plist_path = launchd_plist_path(config);
114 if plist_path.exists() {
115 let plist_arg = plist_path.to_string_lossy().to_string();
116 let _ = launchctl(&["unload", "-w", &plist_arg]).await;
117 fs::remove_file(&plist_path)
118 .await
119 .with_context(|| format!("removing {}", plist_path.display()))?;
120 println!("Uninstalled {LAUNCHD_LABEL}");
121 } else {
122 println!("Not installed");
123 }
124 Ok(())
125}
126
127pub fn handle_url(path: &str, port: u16) -> Result<()> {
128 println!("{}", public_url(path, port)?);
129 Ok(())
130}
131
132pub fn public_url(path: &str, port: u16) -> Result<String> {
133 let rel = normalize_resource_path(path)?;
134 Ok(format!(
135 "http://{DEFAULT_HOST}:{port}/{}",
136 rel.to_string_lossy()
137 ))
138}
139
140pub fn http_root(config: &Config) -> PathBuf {
141 config.shine_dir().join("http")
142}
143
144fn launchd_log_dir(config: &Config) -> PathBuf {
149 config.shine_dir().join("run").join("http")
150}
151
152fn ensure_macos_service_support() -> Result<()> {
153 if !cfg!(target_os = "macos") {
154 bail!("shine serve install is currently supported on macOS only");
155 }
156 Ok(())
157}
158
159fn service_executable(config: &Config) -> Result<PathBuf> {
160 if let Some(dest) = &config.self_install_dest {
161 return Ok(dest.clone());
162 }
163 std::env::current_exe().context("failed to resolve current executable path")
164}
165
166fn launchd_plist_path(config: &Config) -> PathBuf {
167 config
168 .home_dir
169 .join("Library")
170 .join("LaunchAgents")
171 .join(format!("{LAUNCHD_LABEL}.plist"))
172}
173
174fn launchd_plist(executable: &Path, port: u16, log_dir: &Path) -> String {
175 let executable = xml_escape(&executable.display().to_string());
176 let out_log = xml_escape(&log_dir.join("serve.out.log").display().to_string());
177 let err_log = xml_escape(&log_dir.join("serve.err.log").display().to_string());
178 format!(
179 r#"<?xml version="1.0" encoding="UTF-8"?>
180<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
181<plist version="1.0">
182<dict>
183 <key>Label</key>
184 <string>{LAUNCHD_LABEL}</string>
185 <key>ProgramArguments</key>
186 <array>
187 <string>{executable}</string>
188 <string>serve</string>
189 <string>start</string>
190 <string>--port</string>
191 <string>{port}</string>
192 </array>
193 <key>RunAtLoad</key>
194 <true/>
195 <key>KeepAlive</key>
196 <true/>
197 <key>StandardOutPath</key>
198 <string>{out_log}</string>
199 <key>StandardErrorPath</key>
200 <string>{err_log}</string>
201</dict>
202</plist>
203"#
204 )
205}
206
207fn xml_escape(value: &str) -> String {
208 value
209 .replace('&', "&")
210 .replace('<', "<")
211 .replace('>', ">")
212 .replace('"', """)
213 .replace('\'', "'")
214}
215
216async fn launchctl(args: &[&str]) -> Result<()> {
217 let output = Command::new("launchctl")
218 .args(args)
219 .output()
220 .await
221 .with_context(|| format!("running launchctl {}", args.join(" ")))?;
222 if !output.status.success() {
223 let stderr = String::from_utf8_lossy(&output.stderr);
224 let stdout = String::from_utf8_lossy(&output.stdout);
225 let detail = stderr.trim();
226 let detail = if detail.is_empty() {
227 stdout.trim()
228 } else {
229 detail
230 };
231 if detail.is_empty() {
232 bail!("launchctl {} failed with {}", args.join(" "), output.status);
233 }
234 bail!(
235 "launchctl {} failed with {}: {detail}",
236 args.join(" "),
237 output.status
238 );
239 }
240 Ok(())
241}
242
243async fn handle_connection(mut stream: TcpStream, root: PathBuf) -> Result<()> {
244 let request = match read_request(&mut stream).await {
245 Ok(request) => request,
246 Err(_) => {
247 write_response(
248 &mut stream,
249 400,
250 "Bad Request",
251 "text/plain",
252 b"",
253 false,
254 None,
255 )
256 .await?;
257 return Ok(());
258 }
259 };
260
261 if request.method != "GET" && request.method != "HEAD" {
262 write_response(
263 &mut stream,
264 405,
265 "Method Not Allowed",
266 "text/plain",
267 b"",
268 false,
269 None,
270 )
271 .await?;
272 return Ok(());
273 }
274
275 let rel = match normalize_resource_path(&request.path) {
276 Ok(rel) => rel,
277 Err(_) => {
278 write_response(
279 &mut stream,
280 404,
281 "Not Found",
282 "text/plain",
283 b"",
284 false,
285 None,
286 )
287 .await?;
288 return Ok(());
289 }
290 };
291 let root_canon = fs::canonicalize(&root).await?;
292 let candidate = root.join(rel);
293 let file_canon = match fs::canonicalize(&candidate).await {
294 Ok(path) => path,
295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
296 write_response(
297 &mut stream,
298 404,
299 "Not Found",
300 "text/plain",
301 b"",
302 false,
303 None,
304 )
305 .await?;
306 return Ok(());
307 }
308 Err(e) => return Err(e.into()),
309 };
310 if !file_canon.starts_with(&root_canon) || !file_canon.is_file() {
311 write_response(
312 &mut stream,
313 404,
314 "Not Found",
315 "text/plain",
316 b"",
317 false,
318 None,
319 )
320 .await?;
321 return Ok(());
322 }
323
324 let bytes = fs::read(&file_canon).await?;
325 let etag = entity_tag(&bytes);
326 if request
327 .if_none_match
328 .as_deref()
329 .is_some_and(|value| etag_matches(value, &etag))
330 {
331 write_response(
332 &mut stream,
333 304,
334 "Not Modified",
335 content_type(&file_canon),
336 b"",
337 true,
338 Some(&etag),
339 )
340 .await?;
341 return Ok(());
342 }
343 write_response(
344 &mut stream,
345 200,
346 "OK",
347 content_type(&file_canon),
348 &bytes,
349 request.method == "HEAD",
350 Some(&etag),
351 )
352 .await?;
353 Ok(())
354}
355
356struct Request {
357 method: String,
358 path: String,
359 if_none_match: Option<String>,
360}
361
362async fn read_request(stream: &mut TcpStream) -> Result<Request> {
363 let mut data = Vec::new();
364 let mut buf = [0u8; 1024];
365 loop {
366 let n = stream.read(&mut buf).await?;
367 if n == 0 {
368 break;
369 }
370 data.extend_from_slice(&buf[..n]);
371 if data.windows(4).any(|w| w == b"\r\n\r\n") {
372 break;
373 }
374 if data.len() > MAX_HEADER_BYTES {
375 bail!("request header too large");
376 }
377 }
378
379 let header = std::str::from_utf8(&data).context("request header must be utf-8")?;
380 let line = header.lines().next().context("missing request line")?;
381 let mut parts = line.split_whitespace();
382 let method = parts.next().context("missing method")?.to_string();
383 let target = parts.next().context("missing path")?;
384 let path = target
385 .split_once('?')
386 .map(|(path, _)| path)
387 .unwrap_or(target)
388 .to_string();
389 let if_none_match = header.lines().skip(1).find_map(|line| {
390 let (name, value) = line.split_once(':')?;
391 name.eq_ignore_ascii_case("if-none-match")
392 .then(|| value.trim().to_string())
393 });
394 Ok(Request {
395 method,
396 path,
397 if_none_match,
398 })
399}
400
401async fn write_response(
402 stream: &mut TcpStream,
403 status: u16,
404 reason: &str,
405 content_type: &str,
406 body: &[u8],
407 head_only: bool,
408 etag: Option<&str>,
409) -> Result<()> {
410 let cache_headers = cache_headers(etag);
411 let headers = format!(
412 "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\n{cache_headers}Connection: close\r\n\r\n",
413 body.len(),
414 );
415 stream.write_all(headers.as_bytes()).await?;
416 if !head_only {
417 stream.write_all(body).await?;
418 }
419 Ok(())
420}
421
422fn cache_headers(etag: Option<&str>) -> String {
423 let mut headers =
424 "Cache-Control: no-cache, max-age=0, must-revalidate\r\nPragma: no-cache\r\n".to_string();
425 if let Some(etag) = etag {
426 headers.push_str(&format!("ETag: {etag}\r\n"));
427 }
428 headers
429}
430
431fn entity_tag(bytes: &[u8]) -> String {
432 format!("\"sha256-{:x}\"", Sha256::digest(bytes))
433}
434
435fn etag_matches(if_none_match: &str, etag: &str) -> bool {
436 if_none_match
437 .split(',')
438 .map(str::trim)
439 .any(|candidate| candidate == "*" || candidate.trim_start_matches("W/") == etag)
440}
441
442fn normalize_resource_path(path: &str) -> Result<PathBuf> {
443 let path = path.trim_start_matches('/');
444 if path.is_empty() {
445 bail!("resource path must not be empty");
446 }
447 let decoded = percent_decode(path)?;
448 let rel = Path::new(&decoded);
449 if rel.is_absolute() {
450 bail!("resource path must be relative");
451 }
452 if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
453 bail!("resource path must not contain traversal");
454 }
455 Ok(rel.to_path_buf())
456}
457
458fn percent_decode(input: &str) -> Result<String> {
459 let bytes = input.as_bytes();
460 let mut out = Vec::with_capacity(bytes.len());
461 let mut i = 0;
462 while i < bytes.len() {
463 if bytes[i] == b'%' {
464 if i + 2 >= bytes.len() {
465 bail!("invalid percent encoding");
466 }
467 let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?;
468 out.push(u8::from_str_radix(hex, 16).context("invalid percent encoding")?);
469 i += 3;
470 } else {
471 out.push(bytes[i]);
472 i += 1;
473 }
474 }
475 String::from_utf8(out).context("decoded path must be utf-8")
476}
477
478fn content_type(path: &Path) -> &'static str {
479 match path.extension().and_then(|ext| ext.to_str()) {
480 Some("html") => "text/html; charset=utf-8",
481 Some("json") => "application/json",
482 Some("sgmodule" | "txt" | "toml") => "text/plain; charset=utf-8",
483 _ => "application/octet-stream",
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::{
490 cache_headers, entity_tag, etag_matches, launchd_log_dir, launchd_plist,
491 launchd_plist_path, normalize_resource_path, public_url,
492 };
493 use crate::config::Config;
494 use std::path::{Path, PathBuf};
495
496 #[test]
497 fn public_url_uses_single_local_server_root() {
498 assert_eq!(
499 public_url("/app/surge/custom-rules.sgmodule", 6174).unwrap(),
500 "http://127.0.0.1:6174/app/surge/custom-rules.sgmodule"
501 );
502 }
503
504 #[test]
505 fn resource_paths_reject_traversal() {
506 assert!(normalize_resource_path("../secret").is_err());
507 assert!(normalize_resource_path("app/../secret").is_err());
508 assert!(normalize_resource_path("/app/surge/custom-rules.sgmodule").is_ok());
509 assert_eq!(
510 normalize_resource_path("app/surge/custom-rules.sgmodule").unwrap(),
511 Path::new("app/surge/custom-rules.sgmodule")
512 );
513 }
514
515 #[test]
516 fn resource_responses_require_revalidation_and_offer_an_etag() {
517 let etag = entity_tag(b"current rules");
518 let headers = cache_headers(Some(&etag));
519 assert!(headers.contains("Cache-Control: no-cache, max-age=0, must-revalidate"));
520 assert!(headers.contains("Pragma: no-cache"));
521 assert!(headers.contains(&format!("ETag: {etag}")));
522 assert!(etag_matches(&etag, &etag));
523 assert!(etag_matches(&format!("W/{etag}, \"older\""), &etag));
524 assert!(!etag_matches("\"older\"", &etag));
525 }
526
527 #[test]
528 fn launchd_plist_runs_the_single_foreground_server() {
529 let log_dir = Path::new("/Users/tester/.shine/run/http");
530 let plist = launchd_plist(Path::new("/opt/shine & tools/shine"), 6188, log_dir);
531 assert!(plist.contains("<string>top.biulight.shine.http</string>"));
532 assert!(plist.contains("<string>/opt/shine & tools/shine</string>"));
533 assert!(plist.contains("<string>serve</string>"));
534 assert!(plist.contains("<string>start</string>"));
535 assert!(plist.contains("<string>--port</string>"));
536 assert!(plist.contains("<string>6188</string>"));
537 }
538
539 #[test]
540 fn launchd_plist_logs_are_scoped_under_the_user_shine_dir_not_shared_tmp() {
541 let log_dir = Path::new("/Users/tester/.shine/run/http");
542 let plist = launchd_plist(Path::new("/opt/shine/shine"), 6188, log_dir);
543 assert!(plist.contains("<string>/Users/tester/.shine/run/http/serve.out.log</string>"));
544 assert!(plist.contains("<string>/Users/tester/.shine/run/http/serve.err.log</string>"));
545 assert!(!plist.contains("/tmp/"));
546 }
547
548 #[test]
549 fn launchd_plist_path_lives_under_user_launch_agents() {
550 let root = PathBuf::from("/tmp/shine-home");
551 let config = Config::new_for_test(&root);
552 assert_eq!(
553 launchd_plist_path(&config),
554 root.join("Library/LaunchAgents/top.biulight.shine.http.plist")
555 );
556 }
557
558 #[test]
559 fn launchd_log_dir_lives_under_shine_dir_run_not_shared_tmp() {
560 let root = PathBuf::from("/tmp/shine-home");
561 let config = Config::new_for_test(&root);
562 assert_eq!(launchd_log_dir(&config), root.join("run").join("http"));
563 }
564}