Skip to main content

cli/
serve.rs

1use anyhow::{Context, Result, bail};
2use sha2::{Digest, Sha256};
3use std::ffi::{OsStr, OsString};
4use std::path::{Component, Path, PathBuf};
5use std::time::Duration;
6use tokio::fs;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tokio::net::{TcpListener, TcpStream};
9use tokio::process::Command;
10
11use crate::config::Config;
12
13const DEFAULT_HOST: &str = "127.0.0.1";
14const LAUNCHD_LABEL: &str = "top.biulight.shine.http";
15const SYSTEMD_UNIT: &str = "shine-http.service";
16const WINDOWS_TASK: &str = "Shine HTTP Server";
17const MAX_HEADER_BYTES: usize = 8192;
18/// Bounds how long a single connection may take end-to-end (read request + write
19/// response), so a slow-loris style local client can't hold a task open forever.
20const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
21
22// --- Trust boundary ---------------------------------------------------------------------
23//
24// This server binds loopback-only (`DEFAULT_HOST` = 127.0.0.1) and is never reachable from
25// the network, but it has NO authentication of its own: any local OS user account on a
26// shared/multi-user machine can connect to `127.0.0.1:<port>` and read any file under
27// `http_root()`, bypassing the normal filesystem permissions that would otherwise keep
28// other accounts out of this user's home directory. Preset authors must never route secrets
29// or otherwise sensitive content through a `dest` that resolves under `~/.shine/http`.
30// See docs/kb/architecture/invariants.md ยง Local HTTP server.
31
32pub async fn handle_install(config: &Config, port: u16) -> Result<()> {
33    let root = http_root(config);
34    fs::create_dir_all(&root)
35        .await
36        .with_context(|| format!("creating {}", root.display()))?;
37
38    match std::env::consts::OS {
39        "macos" => install_launchd(config, port).await?,
40        "linux" => install_systemd(config, port).await?,
41        "windows" => install_windows_task(config, port).await?,
42        os => bail!("shine serve install is not supported on {os}"),
43    }
44
45    println!("Serving {}", root.display());
46    println!("URL base: http://{DEFAULT_HOST}:{port}/");
47    Ok(())
48}
49
50async fn install_launchd(config: &Config, port: u16) -> Result<()> {
51    let plist_path = launchd_plist_path(config);
52    let parent = plist_path
53        .parent()
54        .context("launchd plist path must have a parent directory")?;
55    fs::create_dir_all(parent)
56        .await
57        .with_context(|| format!("creating {}", parent.display()))?;
58
59    let log_dir = launchd_log_dir(config);
60    fs::create_dir_all(&log_dir)
61        .await
62        .with_context(|| format!("creating {}", log_dir.display()))?;
63
64    let executable = service_executable(config)?;
65    let plist = launchd_plist(&executable, config.shine_dir(), port, &log_dir);
66    fs::write(&plist_path, plist)
67        .await
68        .with_context(|| format!("writing {}", plist_path.display()))?;
69
70    let plist_arg = plist_path.to_string_lossy().to_string();
71    let _ = launchctl(&["unload", "-w", &plist_arg]).await;
72    launchctl(&["load", "-w", &plist_arg]).await?;
73
74    println!("Installed {LAUNCHD_LABEL}");
75    Ok(())
76}
77
78async fn install_systemd(config: &Config, port: u16) -> Result<()> {
79    let unit_path = systemd_unit_path(config);
80    let parent = unit_path
81        .parent()
82        .context("systemd unit path must have a parent directory")?;
83    fs::create_dir_all(parent)
84        .await
85        .with_context(|| format!("creating {}", parent.display()))?;
86
87    let executable = service_executable(config)?;
88    let unit = systemd_unit(&executable, config.shine_dir(), port)?;
89    fs::write(&unit_path, unit)
90        .await
91        .with_context(|| format!("writing {}", unit_path.display()))?;
92
93    systemctl(&["daemon-reload"]).await?;
94    systemctl(&["enable", SYSTEMD_UNIT]).await?;
95    systemctl(&["restart", SYSTEMD_UNIT]).await?;
96    println!("Installed {SYSTEMD_UNIT}");
97    Ok(())
98}
99
100async fn install_windows_task(config: &Config, port: u16) -> Result<()> {
101    let executable = service_executable(config)?;
102    let task_run = windows_task_command(&executable, config.shine_dir(), port)?;
103    if task_run.encode_utf16().count() > 261 {
104        bail!("Windows scheduled task command exceeds the 261-character schtasks limit");
105    }
106    let run_as = windows_current_user()?;
107    let _ = schtasks(&[
108        OsStr::new("/End"),
109        OsStr::new("/TN"),
110        OsStr::new(WINDOWS_TASK),
111    ])
112    .await;
113    schtasks(&[
114        OsStr::new("/Create"),
115        OsStr::new("/SC"),
116        OsStr::new("ONLOGON"),
117        OsStr::new("/TN"),
118        OsStr::new(WINDOWS_TASK),
119        OsStr::new("/TR"),
120        OsStr::new(&task_run),
121        OsStr::new("/RL"),
122        OsStr::new("LIMITED"),
123        OsStr::new("/RU"),
124        &run_as,
125        OsStr::new("/NP"),
126        OsStr::new("/F"),
127    ])
128    .await?;
129    schtasks(&[
130        OsStr::new("/Run"),
131        OsStr::new("/TN"),
132        OsStr::new(WINDOWS_TASK),
133    ])
134    .await?;
135    println!("Installed {WINDOWS_TASK}");
136    Ok(())
137}
138
139pub async fn handle_start(config: &Config, port: u16) -> Result<()> {
140    let root = http_root(config);
141    fs::create_dir_all(&root)
142        .await
143        .with_context(|| format!("creating {}", root.display()))?;
144
145    let listener = TcpListener::bind((DEFAULT_HOST, port))
146        .await
147        .with_context(|| format!("binding {DEFAULT_HOST}:{port}"))?;
148    println!("Serving {}", root.display());
149    println!("URL base: http://{DEFAULT_HOST}:{port}/");
150
151    loop {
152        let (stream, _) = listener.accept().await?;
153        let root = root.clone();
154        tokio::spawn(async move {
155            match tokio::time::timeout(CONNECTION_TIMEOUT, handle_connection(stream, root)).await {
156                Ok(Ok(())) => {}
157                Ok(Err(e)) => eprintln!("shine serve: connection error: {e:#}"),
158                Err(_) => {
159                    eprintln!("shine serve: connection timed out after {CONNECTION_TIMEOUT:?}")
160                }
161            }
162        });
163    }
164}
165
166pub async fn handle_status(config: &Config) -> Result<()> {
167    match std::env::consts::OS {
168        "macos" => status_launchd(config).await,
169        "linux" => status_systemd(config).await,
170        "windows" => status_windows_task().await,
171        os => bail!("shine serve status is not supported on {os}"),
172    }
173}
174
175pub async fn handle_uninstall(config: &Config) -> Result<()> {
176    match std::env::consts::OS {
177        "macos" => uninstall_launchd(config).await,
178        "linux" => uninstall_systemd(config).await,
179        "windows" => uninstall_windows_task().await,
180        os => bail!("shine serve uninstall is not supported on {os}"),
181    }
182}
183
184async fn status_launchd(config: &Config) -> Result<()> {
185    let plist_path = launchd_plist_path(config);
186    if !plist_path.exists() {
187        println!("Not installed");
188        return Ok(());
189    }
190    match launchctl(&["list", LAUNCHD_LABEL]).await {
191        Ok(()) => println!("Installed and running"),
192        Err(_) => println!("Installed but not running"),
193    }
194    println!("Plist: {}", plist_path.display());
195    Ok(())
196}
197
198async fn status_systemd(config: &Config) -> Result<()> {
199    let unit_path = systemd_unit_path(config);
200    if !unit_path.exists() {
201        println!("Not installed");
202        return Ok(());
203    }
204    match systemctl(&["is-active", "--quiet", SYSTEMD_UNIT]).await {
205        Ok(()) => println!("Installed and running"),
206        Err(_) => println!("Installed but not running"),
207    }
208    println!("Unit: {}", unit_path.display());
209    Ok(())
210}
211
212async fn status_windows_task() -> Result<()> {
213    match schtasks(&[
214        OsStr::new("/Query"),
215        OsStr::new("/TN"),
216        OsStr::new(WINDOWS_TASK),
217    ])
218    .await
219    {
220        Ok(()) => println!("Installed"),
221        Err(_) => println!("Not installed"),
222    }
223    Ok(())
224}
225
226async fn uninstall_launchd(config: &Config) -> Result<()> {
227    let plist_path = launchd_plist_path(config);
228    if plist_path.exists() {
229        let plist_arg = plist_path.to_string_lossy().to_string();
230        let _ = launchctl(&["unload", "-w", &plist_arg]).await;
231        fs::remove_file(&plist_path)
232            .await
233            .with_context(|| format!("removing {}", plist_path.display()))?;
234        println!("Uninstalled {LAUNCHD_LABEL}");
235    } else {
236        println!("Not installed");
237    }
238    Ok(())
239}
240
241async fn uninstall_systemd(config: &Config) -> Result<()> {
242    let unit_path = systemd_unit_path(config);
243    if !unit_path.exists() {
244        println!("Not installed");
245        return Ok(());
246    }
247    systemctl(&["disable", "--now", SYSTEMD_UNIT]).await?;
248    fs::remove_file(&unit_path)
249        .await
250        .with_context(|| format!("removing {}", unit_path.display()))?;
251    systemctl(&["daemon-reload"]).await?;
252    println!("Uninstalled {SYSTEMD_UNIT}");
253    Ok(())
254}
255
256async fn uninstall_windows_task() -> Result<()> {
257    let query = schtasks(&[
258        OsStr::new("/Query"),
259        OsStr::new("/TN"),
260        OsStr::new(WINDOWS_TASK),
261    ])
262    .await;
263    if query.is_err() {
264        println!("Not installed");
265        return Ok(());
266    }
267    let _ = schtasks(&[
268        OsStr::new("/End"),
269        OsStr::new("/TN"),
270        OsStr::new(WINDOWS_TASK),
271    ])
272    .await;
273    schtasks(&[
274        OsStr::new("/Delete"),
275        OsStr::new("/TN"),
276        OsStr::new(WINDOWS_TASK),
277        OsStr::new("/F"),
278    ])
279    .await?;
280    println!("Uninstalled {WINDOWS_TASK}");
281    Ok(())
282}
283
284pub fn handle_url(path: &str, port: u16) -> Result<()> {
285    println!("{}", public_url(path, port)?);
286    Ok(())
287}
288
289pub fn public_url(path: &str, port: u16) -> Result<String> {
290    let rel = normalize_resource_path(path)?;
291    Ok(format!(
292        "http://{DEFAULT_HOST}:{port}/{}",
293        rel.to_string_lossy()
294    ))
295}
296
297pub fn http_root(config: &Config) -> PathBuf {
298    config.shine_dir().join("http")
299}
300
301/// Directory for the launchd service's stdout/stderr logs. Deliberately kept out of
302/// `http_root()` (`shine_dir/http`) so log contents are never servable over HTTP, and kept
303/// under the user's own `shine_dir` (not a shared path like `/tmp`) so two OS user accounts
304/// running `shine serve install` never collide on the same log file.
305fn launchd_log_dir(config: &Config) -> PathBuf {
306    config.shine_dir().join("run").join("http")
307}
308
309fn service_executable(config: &Config) -> Result<PathBuf> {
310    if let Some(dest) = &config.self_install_dest {
311        return Ok(dest.clone());
312    }
313    std::env::current_exe().context("failed to resolve current executable path")
314}
315
316fn launchd_plist_path(config: &Config) -> PathBuf {
317    config
318        .home_dir
319        .join("Library")
320        .join("LaunchAgents")
321        .join(format!("{LAUNCHD_LABEL}.plist"))
322}
323
324fn launchd_plist(executable: &Path, shine_dir: &Path, port: u16, log_dir: &Path) -> String {
325    let executable = xml_escape(&executable.display().to_string());
326    let shine_dir = xml_escape(&shine_dir.display().to_string());
327    let out_log = xml_escape(&log_dir.join("serve.out.log").display().to_string());
328    let err_log = xml_escape(&log_dir.join("serve.err.log").display().to_string());
329    format!(
330        r#"<?xml version="1.0" encoding="UTF-8"?>
331<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
332<plist version="1.0">
333<dict>
334  <key>Label</key>
335  <string>{LAUNCHD_LABEL}</string>
336  <key>ProgramArguments</key>
337  <array>
338    <string>{executable}</string>
339    <string>--config-dir</string>
340    <string>{shine_dir}</string>
341    <string>serve</string>
342    <string>start</string>
343    <string>--port</string>
344    <string>{port}</string>
345  </array>
346  <key>RunAtLoad</key>
347  <true/>
348  <key>KeepAlive</key>
349  <true/>
350  <key>StandardOutPath</key>
351  <string>{out_log}</string>
352  <key>StandardErrorPath</key>
353  <string>{err_log}</string>
354</dict>
355</plist>
356"#
357    )
358}
359
360fn systemd_unit_path(config: &Config) -> PathBuf {
361    let config_home = std::env::var_os("XDG_CONFIG_HOME")
362        .map(PathBuf::from)
363        .filter(|path| path.is_absolute())
364        .unwrap_or_else(|| config.home_dir.join(".config"));
365    config_home.join("systemd").join("user").join(SYSTEMD_UNIT)
366}
367
368fn systemd_unit(executable: &Path, shine_dir: &Path, port: u16) -> Result<String> {
369    let executable = executable
370        .to_str()
371        .context("shine executable path is not valid UTF-8")?;
372    let shine_dir = shine_dir
373        .to_str()
374        .context("shine config directory is not valid UTF-8")?;
375    Ok(format!(
376        "[Unit]\nDescription=Shine local HTTP server\n\n[Service]\nType=simple\nExecStart={} --config-dir {} serve start --port {port}\nRestart=on-failure\nRestartSec=2s\n\n[Install]\nWantedBy=default.target\n",
377        systemd_quote(executable),
378        systemd_quote(shine_dir),
379    ))
380}
381
382fn systemd_quote(value: &str) -> String {
383    let escaped = value
384        .replace('\\', "\\\\")
385        .replace('"', "\\\"")
386        .replace('\n', "\\n")
387        .replace('\r', "\\r")
388        .replace('\t', "\\t")
389        .replace('%', "%%")
390        .replace('$', "$$");
391    format!("\"{escaped}\"")
392}
393
394fn windows_task_command(executable: &Path, shine_dir: &Path, port: u16) -> Result<String> {
395    let executable = executable
396        .to_str()
397        .context("shine executable path is not valid Unicode")?;
398    let shine_dir = shine_dir
399        .to_str()
400        .context("shine config directory is not valid Unicode")?;
401    Ok([
402        executable.to_string(),
403        "--config-dir".to_string(),
404        shine_dir.to_string(),
405        "serve".to_string(),
406        "start".to_string(),
407        "--port".to_string(),
408        port.to_string(),
409    ]
410    .iter()
411    .map(|arg| windows_quote_arg(arg))
412    .collect::<Vec<_>>()
413    .join(" "))
414}
415
416fn windows_current_user() -> Result<OsString> {
417    let username = std::env::var_os("USERNAME")
418        .filter(|value| !value.is_empty())
419        .context("USERNAME is not set; cannot register the current-user scheduled task")?;
420    if let Some(domain) = std::env::var_os("USERDOMAIN").filter(|value| !value.is_empty()) {
421        let mut qualified = domain;
422        qualified.push("\\");
423        qualified.push(username);
424        Ok(qualified)
425    } else {
426        Ok(username)
427    }
428}
429
430// Quote one argv element according to the CommandLineToArgvW rules used by Rust's
431// Windows process startup. Backslashes need doubling only when they precede a quote
432// or the closing quote.
433fn windows_quote_arg(value: &str) -> String {
434    let mut quoted = String::from("\"");
435    let mut backslashes = 0;
436    for ch in value.chars() {
437        if ch == '\\' {
438            backslashes += 1;
439        } else if ch == '"' {
440            quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
441            quoted.push('"');
442            backslashes = 0;
443        } else {
444            quoted.push_str(&"\\".repeat(backslashes));
445            backslashes = 0;
446            quoted.push(ch);
447        }
448    }
449    quoted.push_str(&"\\".repeat(backslashes * 2));
450    quoted.push('"');
451    quoted
452}
453
454fn xml_escape(value: &str) -> String {
455    value
456        .replace('&', "&amp;")
457        .replace('<', "&lt;")
458        .replace('>', "&gt;")
459        .replace('"', "&quot;")
460        .replace('\'', "&apos;")
461}
462
463async fn launchctl(args: &[&str]) -> Result<()> {
464    run_command("launchctl", args.iter().map(OsStr::new)).await
465}
466
467async fn systemctl(args: &[&str]) -> Result<()> {
468    let args = std::iter::once(OsStr::new("--user")).chain(args.iter().map(OsStr::new));
469    run_command("systemctl", args).await
470}
471
472async fn schtasks(args: &[&OsStr]) -> Result<()> {
473    run_command("schtasks.exe", args.iter().copied()).await
474}
475
476async fn run_command<'a>(program: &str, args: impl IntoIterator<Item = &'a OsStr>) -> Result<()> {
477    let args: Vec<OsString> = args.into_iter().map(OsStr::to_os_string).collect();
478    let output = Command::new(program)
479        .args(&args)
480        .output()
481        .await
482        .with_context(|| format!("running {program}"))?;
483    if !output.status.success() {
484        let stderr = String::from_utf8_lossy(&output.stderr);
485        let stdout = String::from_utf8_lossy(&output.stdout);
486        let detail = stderr.trim();
487        let detail = if detail.is_empty() {
488            stdout.trim()
489        } else {
490            detail
491        };
492        if detail.is_empty() {
493            bail!("{program} failed with {}", output.status);
494        }
495        bail!("{program} failed with {}: {detail}", output.status);
496    }
497    Ok(())
498}
499
500async fn handle_connection(mut stream: TcpStream, root: PathBuf) -> Result<()> {
501    let request = match read_request(&mut stream).await {
502        Ok(request) => request,
503        Err(_) => {
504            write_response(
505                &mut stream,
506                400,
507                "Bad Request",
508                "text/plain",
509                b"",
510                false,
511                None,
512            )
513            .await?;
514            return Ok(());
515        }
516    };
517
518    if request.method != "GET" && request.method != "HEAD" {
519        write_response(
520            &mut stream,
521            405,
522            "Method Not Allowed",
523            "text/plain",
524            b"",
525            false,
526            None,
527        )
528        .await?;
529        return Ok(());
530    }
531
532    let rel = match normalize_resource_path(&request.path) {
533        Ok(rel) => rel,
534        Err(_) => {
535            write_response(
536                &mut stream,
537                404,
538                "Not Found",
539                "text/plain",
540                b"",
541                false,
542                None,
543            )
544            .await?;
545            return Ok(());
546        }
547    };
548    let root_canon = fs::canonicalize(&root).await?;
549    let candidate = root.join(rel);
550    let file_canon = match fs::canonicalize(&candidate).await {
551        Ok(path) => path,
552        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
553            write_response(
554                &mut stream,
555                404,
556                "Not Found",
557                "text/plain",
558                b"",
559                false,
560                None,
561            )
562            .await?;
563            return Ok(());
564        }
565        Err(e) => return Err(e.into()),
566    };
567    if !file_canon.starts_with(&root_canon) || !file_canon.is_file() {
568        write_response(
569            &mut stream,
570            404,
571            "Not Found",
572            "text/plain",
573            b"",
574            false,
575            None,
576        )
577        .await?;
578        return Ok(());
579    }
580
581    let bytes = fs::read(&file_canon).await?;
582    let etag = entity_tag(&bytes);
583    if request
584        .if_none_match
585        .as_deref()
586        .is_some_and(|value| etag_matches(value, &etag))
587    {
588        write_response(
589            &mut stream,
590            304,
591            "Not Modified",
592            content_type(&file_canon),
593            b"",
594            true,
595            Some(&etag),
596        )
597        .await?;
598        return Ok(());
599    }
600    write_response(
601        &mut stream,
602        200,
603        "OK",
604        content_type(&file_canon),
605        &bytes,
606        request.method == "HEAD",
607        Some(&etag),
608    )
609    .await?;
610    Ok(())
611}
612
613struct Request {
614    method: String,
615    path: String,
616    if_none_match: Option<String>,
617}
618
619async fn read_request(stream: &mut TcpStream) -> Result<Request> {
620    let mut data = Vec::new();
621    let mut buf = [0u8; 1024];
622    loop {
623        let n = stream.read(&mut buf).await?;
624        if n == 0 {
625            break;
626        }
627        data.extend_from_slice(&buf[..n]);
628        if data.windows(4).any(|w| w == b"\r\n\r\n") {
629            break;
630        }
631        if data.len() > MAX_HEADER_BYTES {
632            bail!("request header too large");
633        }
634    }
635
636    let header = std::str::from_utf8(&data).context("request header must be utf-8")?;
637    let line = header.lines().next().context("missing request line")?;
638    let mut parts = line.split_whitespace();
639    let method = parts.next().context("missing method")?.to_string();
640    let target = parts.next().context("missing path")?;
641    let path = target
642        .split_once('?')
643        .map(|(path, _)| path)
644        .unwrap_or(target)
645        .to_string();
646    let if_none_match = header.lines().skip(1).find_map(|line| {
647        let (name, value) = line.split_once(':')?;
648        name.eq_ignore_ascii_case("if-none-match")
649            .then(|| value.trim().to_string())
650    });
651    Ok(Request {
652        method,
653        path,
654        if_none_match,
655    })
656}
657
658async fn write_response(
659    stream: &mut TcpStream,
660    status: u16,
661    reason: &str,
662    content_type: &str,
663    body: &[u8],
664    head_only: bool,
665    etag: Option<&str>,
666) -> Result<()> {
667    let cache_headers = cache_headers(etag);
668    let headers = format!(
669        "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\n{cache_headers}Connection: close\r\n\r\n",
670        body.len(),
671    );
672    stream.write_all(headers.as_bytes()).await?;
673    if !head_only {
674        stream.write_all(body).await?;
675    }
676    Ok(())
677}
678
679fn cache_headers(etag: Option<&str>) -> String {
680    let mut headers =
681        "Cache-Control: no-cache, max-age=0, must-revalidate\r\nPragma: no-cache\r\n".to_string();
682    if let Some(etag) = etag {
683        headers.push_str(&format!("ETag: {etag}\r\n"));
684    }
685    headers
686}
687
688fn entity_tag(bytes: &[u8]) -> String {
689    format!("\"sha256-{:x}\"", Sha256::digest(bytes))
690}
691
692fn etag_matches(if_none_match: &str, etag: &str) -> bool {
693    if_none_match
694        .split(',')
695        .map(str::trim)
696        .any(|candidate| candidate == "*" || candidate.trim_start_matches("W/") == etag)
697}
698
699fn normalize_resource_path(path: &str) -> Result<PathBuf> {
700    let path = path.trim_start_matches('/');
701    if path.is_empty() {
702        bail!("resource path must not be empty");
703    }
704    let decoded = percent_decode(path)?;
705    let rel = Path::new(&decoded);
706    if rel.is_absolute() {
707        bail!("resource path must be relative");
708    }
709    if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
710        bail!("resource path must not contain traversal");
711    }
712    Ok(rel.to_path_buf())
713}
714
715fn percent_decode(input: &str) -> Result<String> {
716    let bytes = input.as_bytes();
717    let mut out = Vec::with_capacity(bytes.len());
718    let mut i = 0;
719    while i < bytes.len() {
720        if bytes[i] == b'%' {
721            if i + 2 >= bytes.len() {
722                bail!("invalid percent encoding");
723            }
724            let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?;
725            out.push(u8::from_str_radix(hex, 16).context("invalid percent encoding")?);
726            i += 3;
727        } else {
728            out.push(bytes[i]);
729            i += 1;
730        }
731    }
732    String::from_utf8(out).context("decoded path must be utf-8")
733}
734
735fn content_type(path: &Path) -> &'static str {
736    match path.extension().and_then(|ext| ext.to_str()) {
737        Some("html") => "text/html; charset=utf-8",
738        Some("json") => "application/json",
739        Some("sgmodule" | "txt" | "toml") => "text/plain; charset=utf-8",
740        _ => "application/octet-stream",
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::{
747        cache_headers, entity_tag, etag_matches, launchd_log_dir, launchd_plist,
748        launchd_plist_path, normalize_resource_path, public_url, systemd_unit, systemd_unit_path,
749        windows_quote_arg, windows_task_command,
750    };
751    use crate::config::Config;
752    use std::path::{Path, PathBuf};
753
754    #[test]
755    fn public_url_uses_single_local_server_root() {
756        assert_eq!(
757            public_url("/app/surge/custom-rules.sgmodule", 6174).unwrap(),
758            "http://127.0.0.1:6174/app/surge/custom-rules.sgmodule"
759        );
760    }
761
762    #[test]
763    fn resource_paths_reject_traversal() {
764        assert!(normalize_resource_path("../secret").is_err());
765        assert!(normalize_resource_path("app/../secret").is_err());
766        assert!(normalize_resource_path("/app/surge/custom-rules.sgmodule").is_ok());
767        assert_eq!(
768            normalize_resource_path("app/surge/custom-rules.sgmodule").unwrap(),
769            Path::new("app/surge/custom-rules.sgmodule")
770        );
771    }
772
773    #[test]
774    fn resource_responses_require_revalidation_and_offer_an_etag() {
775        let etag = entity_tag(b"current rules");
776        let headers = cache_headers(Some(&etag));
777        assert!(headers.contains("Cache-Control: no-cache, max-age=0, must-revalidate"));
778        assert!(headers.contains("Pragma: no-cache"));
779        assert!(headers.contains(&format!("ETag: {etag}")));
780        assert!(etag_matches(&etag, &etag));
781        assert!(etag_matches(&format!("W/{etag}, \"older\""), &etag));
782        assert!(!etag_matches("\"older\"", &etag));
783    }
784
785    #[test]
786    fn launchd_plist_runs_the_single_foreground_server() {
787        let log_dir = Path::new("/Users/tester/.shine/run/http");
788        let plist = launchd_plist(
789            Path::new("/opt/shine & tools/shine"),
790            Path::new("/Users/tester/.shine & tools"),
791            6188,
792            log_dir,
793        );
794        assert!(plist.contains("<string>top.biulight.shine.http</string>"));
795        assert!(plist.contains("<string>/opt/shine &amp; tools/shine</string>"));
796        assert!(plist.contains("<string>--config-dir</string>"));
797        assert!(plist.contains("<string>/Users/tester/.shine &amp; tools</string>"));
798        assert!(plist.contains("<string>serve</string>"));
799        assert!(plist.contains("<string>start</string>"));
800        assert!(plist.contains("<string>--port</string>"));
801        assert!(plist.contains("<string>6188</string>"));
802    }
803
804    #[cfg(unix)]
805    #[test]
806    fn launchd_plist_logs_are_scoped_under_the_user_shine_dir_not_shared_tmp() {
807        let log_dir = Path::new("/Users/tester/.shine/run/http");
808        let plist = launchd_plist(
809            Path::new("/opt/shine/shine"),
810            Path::new("/Users/tester/.shine"),
811            6188,
812            log_dir,
813        );
814        assert!(plist.contains("<string>/Users/tester/.shine/run/http/serve.out.log</string>"));
815        assert!(plist.contains("<string>/Users/tester/.shine/run/http/serve.err.log</string>"));
816        assert!(!plist.contains("/tmp/"));
817    }
818
819    #[test]
820    fn launchd_plist_path_lives_under_user_launch_agents() {
821        let root = PathBuf::from("/tmp/shine-home");
822        let config = Config::new_for_test(&root);
823        assert_eq!(
824            launchd_plist_path(&config),
825            root.join("Library/LaunchAgents/top.biulight.shine.http.plist")
826        );
827    }
828
829    #[test]
830    fn launchd_log_dir_lives_under_shine_dir_run_not_shared_tmp() {
831        let root = PathBuf::from("/tmp/shine-home");
832        let config = Config::new_for_test(&root);
833        assert_eq!(launchd_log_dir(&config), root.join("run").join("http"));
834    }
835
836    #[test]
837    fn systemd_unit_runs_and_restarts_the_user_server() {
838        let unit = systemd_unit(
839            Path::new("/opt/shine % tools/shine"),
840            Path::new("/home/tester/.shine $ state"),
841            6188,
842        )
843        .unwrap();
844        assert!(unit.contains("Type=simple"));
845        assert!(unit.contains(
846            "ExecStart=\"/opt/shine %% tools/shine\" --config-dir \"/home/tester/.shine $$ state\" serve start --port 6188"
847        ));
848        assert!(unit.contains("Restart=on-failure"));
849        assert!(unit.contains("WantedBy=default.target"));
850    }
851
852    #[test]
853    fn systemd_unit_path_uses_the_standard_user_unit_suffix() {
854        let root = PathBuf::from("/tmp/shine-home");
855        let config = Config::new_for_test(&root);
856        assert!(systemd_unit_path(&config).ends_with("systemd/user/shine-http.service"));
857    }
858
859    #[test]
860    fn windows_task_command_preserves_spaces_quotes_and_trailing_backslashes() {
861        let command = windows_task_command(
862            Path::new(r#"C:\Program Files\Shine\shine.exe"#),
863            Path::new(r#"C:\Users\Tester\shine state\"#),
864            6199,
865        )
866        .unwrap();
867        assert!(command.starts_with(r#""C:\Program Files\Shine\shine.exe" "--config-dir""#));
868        assert!(command.contains(r#""C:\Users\Tester\shine state\\""#));
869        assert!(command.ends_with(r#""serve" "start" "--port" "6199""#));
870        assert_eq!(
871            windows_quote_arg(r#"C:\path with space\"#),
872            r#""C:\path with space\\""#
873        );
874        assert_eq!(windows_quote_arg(r#"a"b"#), r#""a\"b""#);
875    }
876}