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;
18const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
21
22pub 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
301fn 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
430fn 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('&', "&")
457 .replace('<', "<")
458 .replace('>', ">")
459 .replace('"', """)
460 .replace('\'', "'")
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 & tools/shine</string>"));
796 assert!(plist.contains("<string>--config-dir</string>"));
797 assert!(plist.contains("<string>/Users/tester/.shine & 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}