Skip to main content

cli/
serve.rs

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;
15/// Bounds how long a single connection may take end-to-end (read request + write
16/// response), so a slow-loris style local client can't hold a task open forever.
17const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
18
19// --- Trust boundary ---------------------------------------------------------------------
20//
21// This server binds loopback-only (`DEFAULT_HOST` = 127.0.0.1) and is never reachable from
22// the network, but it has NO authentication of its own: any local OS user account on a
23// shared/multi-user machine can connect to `127.0.0.1:<port>` and read any file under
24// `http_root()`, bypassing the normal filesystem permissions that would otherwise keep
25// other accounts out of this user's home directory. Preset authors must never route secrets
26// or otherwise sensitive content through a `dest` that resolves under `~/.shine/http`.
27// See docs/kb/architecture/invariants.md ยง Local HTTP server.
28
29pub 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
144/// Directory for the launchd service's stdout/stderr logs. Deliberately kept out of
145/// `http_root()` (`shine_dir/http`) so log contents are never servable over HTTP, and kept
146/// under the user's own `shine_dir` (not a shared path like `/tmp`) so two OS user accounts
147/// running `shine serve install` never collide on the same log file.
148fn 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('&', "&amp;")
210        .replace('<', "&lt;")
211        .replace('>', "&gt;")
212        .replace('"', "&quot;")
213        .replace('\'', "&apos;")
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 &amp; 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}