1use std::path::{Path, PathBuf};
10use std::sync::Mutex as StdMutex;
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::time::{Duration, Instant};
13
14use anyhow::{Context, Result};
15use chromiumoxide::browser::{Browser, BrowserConfig};
16use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
17use chromiumoxide::{Page, error::CdpError};
18use futures::StreamExt;
19use serde::Deserialize;
20use tokio::sync::Mutex;
21use tokio::task::JoinHandle;
22
23pub const SHOP_URL: &str = "https://www.k-ruoka.fi/kauppa";
24pub const SHOP_ORIGIN: &str = "https://www.k-ruoka.fi";
25
26fn on_shop_origin(url: &str) -> bool {
30 match url.strip_prefix(SHOP_ORIGIN) {
31 Some(rest) => rest.is_empty() || rest.starts_with(['/', '?', '#']),
32 None => false,
33 }
34}
35
36const BLOCK_MARKERS: &[&str] = &["Pyyntö estetty", "Attention Required"];
41
42const CHALLENGE_MARKERS: &[&str] = &["Just a moment", "cdn-cgi/challenge"];
47
48fn first_marker(text: &str, markers: &[&'static str]) -> Option<&'static str> {
49 markers.iter().copied().find(|m| text.contains(m))
50}
51
52fn cloudflare_marker(text: &str) -> Option<&'static str> {
56 first_marker(text, BLOCK_MARKERS).or_else(|| first_marker(text, CHALLENGE_MARKERS))
57}
58const CLEARANCE_TIMEOUT: Duration = Duration::from_secs(45);
59
60const GRACEFUL_EXIT: Duration = Duration::from_secs(10);
62
63const CHROME_ARGS: &[&str] = &[
72 "disable-background-networking",
73 "disable-background-timer-throttling",
74 "disable-backgrounding-occluded-windows",
75 "disable-breakpad",
76 "disable-client-side-phishing-detection",
77 "disable-default-apps",
78 "disable-dev-shm-usage",
79 "disable-hang-monitor",
80 "disable-ipc-flooding-protection",
81 "disable-popup-blocking",
82 "disable-prompt-on-repost",
83 "disable-renderer-backgrounding",
84 "disable-sync",
85 "metrics-recording-only",
86 "no-first-run",
87 "password-store=basic",
88 "use-mock-keychain",
89 "disable-blink-features=AutomationControlled",
90 "lang=fi-FI",
91];
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum LaunchMode {
95 Headless,
97 Headful { debug_port: u16 },
99}
100
101#[derive(Debug, thiserror::Error)]
107pub enum ApiError {
108 #[error("Cloudflare blocked us: {detail}")]
115 Cloudflare { detail: String },
116
117 #[error("the browser connection was lost: {detail}")]
120 BrowserGone { detail: String },
121
122 #[error("K-Plussa session has expired -- run `k-ruoka-mcp login` again")]
124 AuthExpired,
125
126 #[error("stale X-K-Build-Number (server wants {wanted:?})")]
134 StaleBuild { wanted: Option<String> },
135
136 #[error("K-Ruoka API error (status {status}): {message}")]
138 Api { status: u16, message: String },
139
140 #[error("{0}")]
146 InvalidRequest(String),
147
148 #[error(transparent)]
149 Other(#[from] anyhow::Error),
150}
151
152#[async_trait::async_trait]
163pub trait KrApi: Send + Sync {
164 async fn call(
167 &self,
168 method: &str,
169 path: &str,
170 body: Option<&serde_json::Value>,
171 ) -> Result<serde_json::Value, ApiError>;
172}
173
174#[async_trait::async_trait]
175impl KrApi for Session {
176 async fn call(
177 &self,
178 method: &str,
179 path: &str,
180 body: Option<&serde_json::Value>,
181 ) -> Result<serde_json::Value, ApiError> {
182 self.api(method, path, body).await
183 }
184}
185
186#[derive(Debug, PartialEq, Eq)]
189enum Recovery {
190 RefreshBuild(String),
192 Relaunch,
194 GiveUp,
196}
197
198fn plan_recovery(error: &ApiError, relaunch_unavailable: bool, refreshed_build: bool) -> Recovery {
203 match error {
204 ApiError::StaleBuild {
207 wanted: Some(build),
208 } if !refreshed_build => Recovery::RefreshBuild(build.clone()),
209 ApiError::Cloudflare { .. } | ApiError::BrowserGone { .. } if !relaunch_unavailable => {
210 Recovery::Relaunch
211 }
212 _ => Recovery::GiveUp,
215 }
216}
217
218const DEFAULT_MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(500);
224
225struct RateLimiter {
230 min_interval: Duration,
231 next_allowed: Mutex<Option<Instant>>,
233}
234
235impl RateLimiter {
236 fn new(min_interval: Duration) -> Self {
237 Self {
238 min_interval,
239 next_allowed: Mutex::new(None),
240 }
241 }
242
243 async fn acquire(&self) {
245 if self.min_interval.is_zero() {
246 return;
247 }
248 let mut slot = self.next_allowed.lock().await;
249 let now = Instant::now();
250 if let Some(next) = *slot
252 && let Some(wait) = next.checked_duration_since(now)
253 {
254 tokio::time::sleep(wait).await;
255 }
256 *slot = Some(Instant::now() + self.min_interval);
257 }
258}
259
260fn min_request_interval() -> Duration {
265 match std::env::var("K_RUOKA_MIN_REQUEST_INTERVAL_MS") {
266 Ok(raw) => match raw.trim().parse::<u64>() {
267 Ok(ms) => Duration::from_millis(ms),
268 Err(_) => {
270 eprintln!(
271 "k-ruoka-mcp: K_RUOKA_MIN_REQUEST_INTERVAL_MS={raw:?} is not a number \
272 of milliseconds; using the default"
273 );
274 DEFAULT_MIN_REQUEST_INTERVAL
275 }
276 },
277 Err(_) => DEFAULT_MIN_REQUEST_INTERVAL,
278 }
279}
280
281fn closed_underneath_us() -> ApiError {
288 ApiError::Other(anyhow::anyhow!(
289 "the server is shutting down; no new browser will be started"
290 ))
291}
292
293fn relaunch_costs_a_human_their_login(mode: LaunchMode) -> bool {
305 match mode {
306 LaunchMode::Headful { .. } => true,
307 LaunchMode::Headless => false,
308 }
309}
310
311fn should_replace(current: Option<u64>, blocked: Option<u64>) -> bool {
319 match (current, blocked) {
320 (None, _) => true,
322 (Some(current), Some(blocked)) => current == blocked,
324 (Some(_), None) => false,
327 }
328}
329
330#[derive(Debug, Deserialize)]
332struct RawResponse {
333 status: u16,
334 build: Option<String>,
335 #[serde(rename = "cfMitigated")]
336 cf_mitigated: Option<String>,
337 #[serde(rename = "contentType")]
338 content_type: Option<String>,
339 body: String,
340}
341
342#[derive(Debug, Deserialize)]
345struct ApiErrorBody {
346 error: ApiErrorInner,
347}
348
349#[derive(Debug, Deserialize)]
350struct ApiErrorInner {
351 message: String,
352}
353
354struct Live {
355 browser: Browser,
356 page: Page,
357 handler: JoinHandle<()>,
358 generation: u64,
365}
366
367impl Live {
368 async fn shutdown(mut self) {
377 let asked_to_exit = self.browser.close().await.is_ok();
383 let exited = asked_to_exit
384 && tokio::time::timeout(GRACEFUL_EXIT, self.browser.wait())
385 .await
386 .is_ok();
387 if !exited {
388 self.browser.kill().await;
389 self.browser.wait().await.ok();
390 }
391 self.handler.abort();
392 }
393}
394
395pub struct Session {
396 profile: PathBuf,
397 mode: LaunchMode,
398 user_agent: std::sync::OnceLock<String>,
402 live: Mutex<Option<Live>>,
403 closed: AtomicBool,
409 shutting_down: AtomicBool,
411 login_in_progress: AtomicBool,
414 next_generation: AtomicU64,
416 build: Mutex<Option<String>>,
419 limiter: RateLimiter,
421 last_activity: StdMutex<Instant>,
423 active_browser_ops: AtomicUsize,
425}
426
427struct BrowserActivity<'a> {
428 session: &'a Session,
429}
430
431impl Drop for BrowserActivity<'_> {
432 fn drop(&mut self) {
433 *self.session.last_activity.lock().unwrap() = Instant::now();
434 self.session
435 .active_browser_ops
436 .fetch_sub(1, Ordering::Relaxed);
437 }
438}
439
440impl Session {
441 pub fn new(profile: impl Into<PathBuf>, mode: LaunchMode) -> Result<Self> {
442 let profile = profile.into();
443 ensure_private_dir(&profile)?;
444 Ok(Self {
445 profile,
446 mode,
447 user_agent: std::sync::OnceLock::new(),
448 live: Mutex::new(None),
449 closed: AtomicBool::new(false),
450 shutting_down: AtomicBool::new(false),
451 login_in_progress: AtomicBool::new(false),
452 next_generation: AtomicU64::new(0),
453 build: Mutex::new(None),
454 limiter: RateLimiter::new(min_request_interval()),
455 last_activity: StdMutex::new(Instant::now()),
456 active_browser_ops: AtomicUsize::new(0),
457 })
458 }
459
460 async fn begin_browser_activity(&self) -> BrowserActivity<'_> {
461 let _guard = self.live.lock().await;
462 self.active_browser_ops.fetch_add(1, Ordering::Relaxed);
463 *self.last_activity.lock().unwrap() = Instant::now();
464 BrowserActivity { session: self }
465 }
466
467 pub fn user_agent(&self) -> Result<&str> {
475 if let Some(ua) = self.user_agent.get() {
476 return Ok(ua);
477 }
478 let derived = user_agent()?;
482 Ok(self.user_agent.get_or_init(|| derived))
483 }
484
485 pub async fn set_build(&self, build: Option<String>) {
491 *self.build.lock().await = build;
492 }
493
494 async fn ensure_live(&self) -> Result<(), ApiError> {
499 let mut guard = self.live.lock().await;
500 self.refuse_if_unavailable()?;
501 if let Some(live) = guard.as_ref() {
502 match live.page.url().await {
505 Ok(Some(url)) if on_shop_origin(&url) => return Ok(()),
507 Ok(_) => {
508 if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
514 if let Some(dead) = guard.take() {
515 dead.shutdown().await;
516 }
517 return Err(e);
518 }
519 return Ok(());
520 }
521 Err(_) => {
522 if let Some(dead) = guard.take() {
524 dead.handler.abort();
525 }
526 }
527 }
528 }
529
530 let live = self.launch().await.map_err(ApiError::Other)?;
531 if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
532 live.shutdown().await;
533 return Err(e);
534 }
535 *guard = Some(live);
536 Ok(())
537 }
538
539 async fn launch(&self) -> Result<Live> {
540 let mut builder = BrowserConfig::builder()
541 .chrome_executable(chrome_path())
542 .user_data_dir(&self.profile)
543 .no_sandbox();
544 builder = match self.mode {
545 LaunchMode::Headless => builder.new_headless_mode(),
546 LaunchMode::Headful { debug_port } => builder.with_head().port(debug_port),
547 };
548 let config = builder
549 .disable_default_args()
550 .args(CHROME_ARGS.iter().copied())
551 .arg(format!("user-agent={}", self.user_agent()?))
552 .window_size(1440, 900)
553 .build()
554 .map_err(|e| anyhow::anyhow!("building BrowserConfig: {e}"))?;
555
556 let (browser, mut handler) = Browser::launch(config).await.with_context(|| {
557 format!(
558 "launching Chrome against profile {}. A Chrome left over from an \
559 earlier run can hold the profile's lock -- check for one \
560 (pkill -f {}) before considering the profile itself broken, because \
561 it holds your login and re-running `login` is the only way back.",
562 self.profile.display(),
563 self.profile.display()
564 )
565 })?;
566 let handler = tokio::spawn(async move { while handler.next().await.is_some() {} });
567 let page = browser.new_page("about:blank").await?;
568 let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
569 eprintln!("k-ruoka-mcp: launched browser generation {generation}");
574 Ok(Live {
575 browser,
576 page,
577 handler,
578 generation,
579 })
580 }
581
582 async fn current_page(&self) -> Result<(Page, u64), ApiError> {
591 self.ensure_live().await?;
592 let guard = self.live.lock().await;
593 let live = guard.as_ref().ok_or_else(closed_underneath_us)?;
594 Ok((live.page.clone(), live.generation))
595 }
596
597 pub async fn open_extra_page(&self, url: &str) -> Result<Page> {
605 let _activity = self.begin_browser_activity().await;
606 self.ensure_live().await?;
607 let guard = self.live.lock().await;
608 let browser = &guard.as_ref().ok_or_else(closed_underneath_us)?.browser;
610 Ok(browser.new_page(url).await?)
611 }
612
613 pub async fn with_page<T, F, Fut>(&self, f: F) -> Result<T>
615 where
616 F: FnOnce(Page) -> Fut,
617 Fut: std::future::Future<Output = Result<T>>,
618 {
619 let _activity = self.begin_browser_activity().await;
620 self.ensure_live().await?;
621 let page = {
622 let guard = self.live.lock().await;
623 guard
625 .as_ref()
626 .ok_or_else(closed_underneath_us)?
627 .page
628 .clone()
629 };
630 f(page).await
631 }
632
633 pub fn signal_shutdown(&self) {
642 self.shutting_down.store(true, Ordering::Relaxed);
643 }
644
645 pub async fn close(&self) -> Result<()> {
646 self.signal_shutdown();
648 let mut guard = self.live.lock().await;
649 self.closed.store(true, Ordering::Relaxed);
655 if let Some(live) = guard.take() {
656 live.shutdown().await;
657 }
658 Ok(())
659 }
660
661 pub async fn release_for_login(&self) -> Result<(), ApiError> {
669 let _activity = self.begin_browser_activity().await;
670 let mut guard = self.live.lock().await;
671 self.refuse_if_unavailable()?;
672 self.login_in_progress.store(true, Ordering::Relaxed);
674 if let Some(live) = guard.take() {
675 live.shutdown().await;
676 }
677 Ok(())
678 }
679
680 pub async fn resume_after_login(&self) {
685 let _guard = self.live.lock().await;
686 self.login_in_progress.store(false, Ordering::Relaxed);
687 }
688
689 pub async fn close_if_idle(&self, timeout: Duration) -> Result<bool> {
693 if timeout.is_zero() || self.active_browser_ops.load(Ordering::Relaxed) != 0 {
694 return Ok(false);
695 }
696 if self.last_activity.lock().unwrap().elapsed() < timeout {
697 return Ok(false);
698 }
699 let mut guard = self.live.lock().await;
700 if self.active_browser_ops.load(Ordering::Relaxed) != 0 {
701 return Ok(false);
702 }
703 if self.last_activity.lock().unwrap().elapsed() < timeout {
704 return Ok(false);
705 }
706 if let Some(live) = guard.take() {
707 live.shutdown().await;
708 *self.last_activity.lock().unwrap() = Instant::now();
709 return Ok(true);
710 }
711 Ok(false)
712 }
713
714 fn refuse_if_unavailable(&self) -> Result<(), ApiError> {
716 if self.shutting_down.load(Ordering::Relaxed) {
718 return Err(closed_underneath_us());
719 }
720 if self.closed.load(Ordering::Relaxed) {
721 return Err(closed_underneath_us());
722 }
723 if self.login_in_progress.load(Ordering::Relaxed) {
724 return Err(ApiError::InvalidRequest(
725 "a login is in progress and it owns the browser profile until it \
726 finishes. Call login_status to see how it is going, or cancel_login to \
727 give up, then retry."
728 .to_string(),
729 ));
730 }
731 Ok(())
732 }
733
734 async fn relaunch(&self, blocked: Option<u64>) -> Result<(), ApiError> {
746 let mut guard = self.live.lock().await;
747 self.refuse_if_unavailable()?;
748 if !should_replace(guard.as_ref().map(|live| live.generation), blocked) {
749 return Ok(());
750 }
751 if let Some(mut dead) = guard.take() {
752 dead.browser.close().await.ok();
753 dead.browser.wait().await.ok();
754 dead.handler.abort();
755 }
756 let live = self.launch().await.map_err(ApiError::Other)?;
757 if let Err(e) = navigate_and_clear(&live.page, &self.shutting_down).await {
758 live.shutdown().await;
759 return Err(e);
760 }
761 *guard = Some(live);
762 Ok(())
763 }
764
765 pub async fn api(
768 &self,
769 method: &str,
770 path: &str,
771 body: Option<&serde_json::Value>,
772 ) -> Result<serde_json::Value, ApiError> {
773 let _activity = self.begin_browser_activity().await;
774 let mut relaunched = false;
775 let mut refreshed_build = false;
776 let relaunch_would_hurt = relaunch_costs_a_human_their_login(self.mode);
777 loop {
778 let (generation, result) = self.attempt_once(method, path, body).await;
779 let error = match result {
780 Ok(value) => return Ok(value),
781 Err(e) => e,
782 };
783 match plan_recovery(&error, relaunched || relaunch_would_hurt, refreshed_build) {
784 Recovery::RefreshBuild(build) => {
785 refreshed_build = true;
786 eprintln!("k-ruoka-mcp: build number was stale, retrying with {build}");
790 *self.build.lock().await = Some(build);
791 }
792 Recovery::Relaunch => {
793 relaunched = true;
794 eprintln!(
795 "k-ruoka-mcp: {error}; relaunching the browser against the \
796 same profile (never deleting it) and retrying once"
797 );
798 self.relaunch(generation).await?;
799 }
800 Recovery::GiveUp => return Err(error),
801 }
802 }
803 }
804
805 async fn attempt_once(
825 &self,
826 method: &str,
827 path: &str,
828 body: Option<&serde_json::Value>,
829 ) -> (Option<u64>, Result<serde_json::Value, ApiError>) {
830 match self.current_page().await {
831 Ok((page, generation)) => (
832 Some(generation),
833 self.api_once(&page, method, path, body).await,
834 ),
835 Err(e) => (None, Err(e)),
836 }
837 }
838
839 async fn api_once(
840 &self,
841 page: &Page,
842 method: &str,
843 path: &str,
844 body: Option<&serde_json::Value>,
845 ) -> Result<serde_json::Value, ApiError> {
846 let build = self.build.lock().await.clone();
847
848 self.limiter.acquire().await;
851
852 let expr = fetch_script(method, path, body, build.as_deref());
853 let value = evaluate(page, &expr).await?;
854 let raw: RawResponse = serde_json::from_value(value)
855 .context("unexpected shape from the in-page fetch helper")
856 .map_err(ApiError::Other)?;
857
858 if let Some(b) = &raw.build {
861 let mut slot = self.build.lock().await;
862 if slot.as_deref() != Some(b.as_str()) {
863 *slot = Some(b.clone());
864 }
865 }
866
867 classify(raw)
868 }
869}
870
871fn classify(raw: RawResponse) -> Result<serde_json::Value, ApiError> {
878 let looks_html = raw
879 .content_type
880 .as_deref()
881 .is_some_and(|c| c.contains("text/html"))
882 || raw.body.trim_start().starts_with('<');
883
884 let challenge_marker = cloudflare_marker(&raw.body).is_some();
890 let cf_status = matches!(raw.status, 403 | 503);
895
896 let success = (200..300).contains(&raw.status);
901 if raw.cf_mitigated.is_some() || (!success && (challenge_marker || (looks_html && cf_status))) {
902 let mitigated = raw
903 .cf_mitigated
904 .as_deref()
905 .map(|m| format!(", cf-mitigated: {m}"))
906 .unwrap_or_default();
907 return Err(ApiError::Cloudflare {
908 detail: format!("API response, status {}{mitigated}", raw.status),
909 });
910 }
911
912 let message = serde_json::from_str::<ApiErrorBody>(&raw.body)
913 .ok()
914 .map(|b| b.error.message);
915
916 if (200..300).contains(&raw.status) {
917 return serde_json::from_str(&raw.body)
918 .context("K-Ruoka returned a success status with a body that is not JSON")
919 .map_err(ApiError::Other);
920 }
921
922 if let Some(msg) = &message {
926 if msg.contains("Client version is too old") {
927 return Err(ApiError::StaleBuild { wanted: raw.build });
928 }
929 if msg.contains("Token renewal error") {
930 return Err(ApiError::AuthExpired);
931 }
932 }
933 if raw.status == 401 {
934 return Err(ApiError::AuthExpired);
935 }
936 Err(ApiError::Api {
937 status: raw.status,
938 message: message.unwrap_or_else(|| raw.body.chars().take(300).collect()),
939 })
940}
941
942fn fetch_script(
948 method: &str,
949 path: &str,
950 body: Option<&serde_json::Value>,
951 build: Option<&str>,
952) -> String {
953 let body_js = match body {
954 Some(b) => format!("JSON.stringify({b})"),
955 None => "undefined".to_string(),
956 };
957 format!(
958 r#"(async () => {{
959 const headers = {{ 'Accept': 'application/json' }};
960 const build = {build};
961 if (build) headers['X-K-Build-Number'] = build;
962 const body = {body_js};
963 if (body !== undefined) headers['Content-Type'] = 'application/json';
964 const r = await fetch({path}, {{
965 method: {method},
966 headers,
967 body,
968 credentials: 'include',
969 }});
970 return {{
971 status: r.status,
972 build: r.headers.get('k-ruoka-build'),
973 cfMitigated: r.headers.get('cf-mitigated'),
974 contentType: r.headers.get('content-type'),
975 body: await r.text(),
976 }};
977 }})()"#,
978 build = serde_json::json!(build),
979 path = serde_json::json!(path),
980 method = serde_json::json!(method),
981 )
982}
983
984fn cdp_error_to_api_error(what: &str, e: CdpError) -> ApiError {
988 match e {
989 CdpError::Timeout => ApiError::Other(anyhow::anyhow!("{what} timed out")),
990 CdpError::Ws(_) | CdpError::ChannelSendError(_) | CdpError::Io(_) => {
991 ApiError::BrowserGone {
992 detail: format!("{what}: {e}"),
993 }
994 }
995 other => ApiError::Other(anyhow::anyhow!("{what}: {other}")),
996 }
997}
998
999pub(crate) async fn evaluate(page: &Page, expr: &str) -> Result<serde_json::Value, ApiError> {
1000 let params = EvaluateParams::builder()
1001 .expression(expr)
1002 .await_promise(true)
1003 .return_by_value(true)
1004 .build()
1005 .map_err(|e| ApiError::Other(anyhow::anyhow!("building EvaluateParams: {e}")))?;
1006 let result = page
1007 .evaluate(params)
1008 .await
1009 .map_err(|e| cdp_error_to_api_error("page evaluation", e))?;
1010 Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
1011}
1012
1013const CLEARANCE_POLL_INTERVAL: Duration = Duration::from_millis(250);
1014
1015#[derive(Debug, PartialEq, Eq)]
1017enum ClearanceStep {
1018 Ready,
1019 Refused(&'static str),
1021 ShuttingDown,
1022 TimedOut,
1023 KeepWaiting,
1024}
1025
1026fn clearance_step(
1030 origin: &str,
1031 text: Option<&str>,
1032 shutting_down: bool,
1033 past_deadline: bool,
1034) -> ClearanceStep {
1035 if let Some(marker) = text.and_then(|t| first_marker(t, BLOCK_MARKERS)) {
1036 return ClearanceStep::Refused(marker);
1037 }
1038
1039 let challenged = text.is_some_and(|t| first_marker(t, CHALLENGE_MARKERS).is_some());
1042 if !challenged && origin == SHOP_ORIGIN && text.is_some_and(|t| !t.trim().is_empty()) {
1043 return ClearanceStep::Ready;
1044 }
1045 if shutting_down {
1046 return ClearanceStep::ShuttingDown;
1047 }
1048 if past_deadline {
1049 return ClearanceStep::TimedOut;
1050 }
1051 ClearanceStep::KeepWaiting
1052}
1053
1054async fn navigate_and_clear(page: &Page, shutting_down: &AtomicBool) -> Result<(), ApiError> {
1057 if shutting_down.load(Ordering::Relaxed) {
1059 return Err(closed_underneath_us());
1060 }
1061 page.goto(SHOP_URL)
1062 .await
1063 .map_err(|e| cdp_error_to_api_error(&format!("navigating to {SHOP_URL}"), e))?;
1064 let deadline = Instant::now() + CLEARANCE_TIMEOUT;
1065 loop {
1066 let probe = match evaluate(
1070 page,
1071 "({ origin: location.origin, text: document.body ? document.body.innerText : null })",
1072 )
1073 .await
1074 {
1075 Ok(v) => v,
1076 Err(e @ ApiError::BrowserGone { .. }) => return Err(e),
1078 Err(_) => serde_json::Value::Null,
1079 };
1080 let origin = probe["origin"].as_str().unwrap_or_default();
1081 let text = probe["text"].as_str();
1082
1083 match clearance_step(
1084 origin,
1085 text,
1086 shutting_down.load(Ordering::Relaxed),
1087 Instant::now() > deadline,
1088 ) {
1089 ClearanceStep::Ready => return Ok(()),
1090 ClearanceStep::Refused(marker) => {
1091 return Err(ApiError::Cloudflare {
1092 detail: format!(
1093 "page load rejected ({marker:?}) -- the browser fingerprint is being \
1094 refused; a UA containing HeadlessChrome is the usual cause"
1095 ),
1096 });
1097 }
1098 ClearanceStep::ShuttingDown => return Err(closed_underneath_us()),
1100 ClearanceStep::TimedOut => {
1101 return Err(ApiError::Cloudflare {
1103 detail: format!(
1104 "challenge did not clear within {}s",
1105 CLEARANCE_TIMEOUT.as_secs()
1106 ),
1107 });
1108 }
1109 ClearanceStep::KeepWaiting => {}
1110 }
1111 tokio::time::sleep(CLEARANCE_POLL_INTERVAL).await;
1112 }
1113}
1114
1115const CHROME_CANDIDATES: &[&str] = if cfg!(target_os = "macos") {
1120 &[
1121 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1122 "/Applications/Chromium.app/Contents/MacOS/Chromium",
1123 ]
1124} else if cfg!(target_os = "windows") {
1125 &[
1126 r"C:\Program Files\Google\Chrome\Application\chrome.exe",
1127 r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
1128 ]
1129} else {
1130 &[
1131 "/usr/bin/google-chrome",
1132 "/usr/bin/google-chrome-stable",
1133 "/usr/bin/chromium",
1134 "/usr/bin/chromium-browser",
1135 "/snap/bin/chromium",
1136 ]
1137};
1138
1139fn chrome_path() -> String {
1140 if let Ok(explicit) = std::env::var("K_RUOKA_CHROME") {
1141 return explicit;
1142 }
1143 CHROME_CANDIDATES
1144 .iter()
1145 .find(|candidate| Path::new(candidate).is_file())
1146 .unwrap_or(&CHROME_CANDIDATES[0])
1147 .to_string()
1148}
1149
1150fn user_agent() -> Result<String> {
1159 if let Ok(ua) = std::env::var("K_RUOKA_USER_AGENT") {
1164 return Ok(ua);
1165 }
1166 let version = chrome_version()?;
1167 Ok(format!(
1168 "Mozilla/5.0 ({UA_PLATFORM}) AppleWebKit/537.36 (KHTML, like Gecko) \
1169 Chrome/{version} Safari/537.36"
1170 ))
1171}
1172
1173const UA_PLATFORM: &str = if cfg!(target_os = "macos") {
1179 "Macintosh; Intel Mac OS X 10_15_7"
1180} else if cfg!(target_os = "windows") {
1181 "Windows NT 10.0; Win64; x64"
1182} else {
1183 "X11; Linux x86_64"
1184};
1185
1186fn chrome_version() -> Result<String> {
1188 let path = chrome_path();
1189
1190 #[cfg(not(windows))]
1194 if let Some(version) = version_from_probe(&path) {
1195 return Ok(version);
1196 }
1197
1198 if let Some(version) = version_from_install_dir(Path::new(&path)) {
1201 return Ok(version);
1202 }
1203
1204 anyhow::bail!(
1205 "could not determine the Chrome version from `{path} --version` or from the \
1206 install directory. Set K_RUOKA_USER_AGENT to a full User-Agent string to bypass \
1207 this, or K_RUOKA_CHROME if that path is wrong."
1208 )
1209}
1210
1211#[cfg(not(windows))]
1218const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
1219
1220#[cfg(not(windows))]
1222fn version_from_probe(path: &str) -> Option<String> {
1223 let mut child = std::process::Command::new(path)
1226 .arg("--version")
1227 .stdin(std::process::Stdio::null())
1228 .stdout(std::process::Stdio::piped())
1229 .stderr(std::process::Stdio::null())
1230 .spawn()
1231 .ok()?;
1232
1233 let deadline = Instant::now() + VERSION_PROBE_TIMEOUT;
1234 while Instant::now() < deadline {
1235 match child.try_wait() {
1236 Ok(Some(_)) => {
1237 let mut stdout = String::new();
1238 use std::io::Read;
1239 child.stdout.take()?.read_to_string(&mut stdout).ok()?;
1240 return first_version_token(&stdout);
1241 }
1242 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
1243 Err(_) => return None,
1244 }
1245 }
1246 let _ = child.kill();
1249 let _ = child.wait();
1250 None
1251}
1252
1253fn first_version_token(text: &str) -> Option<String> {
1254 text.split_whitespace()
1255 .find(|token| {
1256 token.chars().next().is_some_and(|c| c.is_ascii_digit()) && token.contains('.')
1257 })
1258 .map(str::to_string)
1259}
1260
1261fn version_from_install_dir(exe: &Path) -> Option<String> {
1263 let dir = exe.parent()?;
1264 let mut best: Option<(Vec<u64>, String)> = None;
1265 for entry in std::fs::read_dir(dir).ok()?.flatten() {
1266 if !entry.file_type().is_ok_and(|t| t.is_dir()) {
1268 continue;
1269 }
1270 let name = entry.file_name().to_string_lossy().into_owned();
1271 let Some(parts) = version_parts(&name) else {
1272 continue;
1273 };
1274 if best.as_ref().is_none_or(|(seen, _)| seen < &parts) {
1278 best = Some((parts, name));
1279 }
1280 }
1281 best.map(|(_, name)| name)
1282}
1283
1284fn version_parts(name: &str) -> Option<Vec<u64>> {
1287 let parts: Vec<&str> = name.split('.').collect();
1288 if parts.len() != 4 {
1289 return None;
1290 }
1291 parts.iter().map(|p| p.parse::<u64>().ok()).collect()
1292}
1293
1294#[cfg(unix)]
1297fn ensure_private_dir(path: &Path) -> Result<()> {
1298 use std::os::unix::fs::PermissionsExt;
1299 std::fs::create_dir_all(path)
1300 .with_context(|| format!("creating profile dir {}", path.display()))?;
1301 let mut perms = std::fs::metadata(path)?.permissions();
1302 if perms.mode() & 0o077 != 0 {
1303 perms.set_mode(0o700);
1304 std::fs::set_permissions(path, perms)
1305 .with_context(|| format!("tightening permissions on {}", path.display()))?;
1306 }
1307 Ok(())
1308}
1309
1310#[cfg(windows)]
1314fn ensure_private_dir(path: &Path) -> Result<()> {
1315 std::fs::create_dir_all(path)
1316 .with_context(|| format!("creating profile dir {}", path.display()))?;
1317 Ok(())
1318}
1319
1320pub fn default_profile_dir() -> Result<PathBuf> {
1323 if let Some(dir) = std::env::var_os("K_RUOKA_PROFILE") {
1324 return Ok(PathBuf::from(dir));
1325 }
1326 Ok(platform_data_dir()?.join("k-ruoka-mcp/profile"))
1327}
1328
1329pub fn default_store_path(profile_dir: &Path) -> PathBuf {
1335 profile_dir
1336 .parent()
1337 .unwrap_or(profile_dir)
1338 .join("default_store")
1339}
1340
1341#[cfg(target_os = "linux")]
1342fn platform_data_dir() -> Result<PathBuf> {
1343 std::env::var_os("XDG_DATA_HOME")
1344 .map(PathBuf::from)
1345 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
1346 .context("neither XDG_DATA_HOME nor HOME is set")
1347}
1348
1349#[cfg(target_os = "macos")]
1350fn platform_data_dir() -> Result<PathBuf> {
1351 std::env::var_os("XDG_DATA_HOME")
1353 .map(PathBuf::from)
1354 .or_else(|| {
1355 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
1356 })
1357 .context("HOME is not set")
1358}
1359
1360#[cfg(windows)]
1361fn platform_data_dir() -> Result<PathBuf> {
1362 std::env::var_os("LOCALAPPDATA")
1363 .map(PathBuf::from)
1364 .or_else(|| std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("AppData/Local")))
1365 .context("neither LOCALAPPDATA nor USERPROFILE is set")
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371
1372 #[test]
1375 fn the_chrome_version_is_read_from_a_sibling_install_directory() {
1376 let root = std::env::temp_dir().join("k-ruoka-version-dir-test");
1377 let _ = std::fs::remove_dir_all(&root);
1378 let app = root.join("Application");
1379 for name in [
1382 "138.0.7204.183",
1383 "138.0.7204.97",
1384 "Dictionaries",
1385 "SetupMetrics",
1386 ] {
1387 std::fs::create_dir_all(app.join(name)).unwrap();
1388 }
1389 let exe = app.join("chrome.exe");
1390 std::fs::write(&exe, b"").unwrap();
1391
1392 assert_eq!(
1393 version_from_install_dir(&exe).as_deref(),
1394 Some("138.0.7204.183"),
1395 "the newest four-part directory, ignoring Chrome's other siblings"
1396 );
1397
1398 std::fs::remove_dir_all(app.join("138.0.7204.183")).unwrap();
1399 std::fs::remove_dir_all(app.join("138.0.7204.97")).unwrap();
1400 assert_eq!(
1401 version_from_install_dir(&exe),
1402 None,
1403 "no version directory is a failure to report, not a version to invent"
1404 );
1405 let _ = std::fs::remove_dir_all(&root);
1406 }
1407
1408 fn raw(status: u16, body: &str, ct: &str, cf: Option<&str>) -> RawResponse {
1409 RawResponse {
1410 status,
1411 build: Some("31844".into()),
1412 cf_mitigated: cf.map(str::to_string),
1413 content_type: Some(ct.into()),
1414 body: body.into(),
1415 }
1416 }
1417
1418 const JSON: &str = "application/json; charset=utf-8";
1419
1420 #[test]
1421 fn success_returns_parsed_body() {
1422 let v = classify(raw(200, r#"{"id":"abc"}"#, JSON, None)).unwrap();
1423 assert_eq!(v["id"], "abc");
1424 }
1425
1426 #[test]
1427 fn stale_build_is_recoverable_and_carries_the_wanted_value() {
1428 let body = r#"{"error":{"message":"Client version is too old - reload"}}"#;
1429 match classify(raw(409, body, JSON, None)) {
1430 Err(ApiError::StaleBuild { wanted }) => assert_eq!(wanted.as_deref(), Some("31844")),
1431 other => panic!("expected StaleBuild, got {other:?}"),
1432 }
1433 }
1434
1435 #[test]
1438 fn token_renewal_is_auth_expiry_not_a_stale_build() {
1439 let body = r#"{"error":{"message":"Token renewal error - reload"}}"#;
1440 assert!(matches!(
1441 classify(raw(409, body, JSON, None)),
1442 Err(ApiError::AuthExpired)
1443 ));
1444 assert!(matches!(
1445 classify(raw(401, "{}", JSON, None)),
1446 Err(ApiError::AuthExpired)
1447 ));
1448 }
1449
1450 #[test]
1451 fn html_body_or_cf_header_is_a_cloudflare_block() {
1452 let html = "<!DOCTYPE html><title>Just a moment...</title>";
1453 assert!(matches!(
1454 classify(raw(403, html, "text/html", None)),
1455 Err(ApiError::Cloudflare { .. })
1456 ));
1457 match classify(raw(403, "{}", JSON, Some("challenge"))) {
1459 Err(ApiError::Cloudflare { detail }) => {
1460 assert!(detail.contains("cf-mitigated: challenge"), "{detail}")
1461 }
1462 other => panic!("expected Cloudflare, got {other:?}"),
1463 }
1464 }
1465
1466 #[test]
1469 fn html_429_is_not_treated_as_a_relaunchable_block() {
1470 let html = "<html><body>Too many requests</body></html>";
1471 assert!(matches!(
1472 classify(raw(429, html, "text/html", None)),
1473 Err(ApiError::Api { status: 429, .. })
1474 ));
1475 }
1476
1477 #[test]
1481 fn bare_401_is_auth_expiry() {
1482 assert!(matches!(
1483 classify(raw(401, "", "application/json", None)),
1484 Err(ApiError::AuthExpired)
1485 ));
1486 }
1487
1488 #[test]
1492 fn a_2xx_whose_body_mentions_a_challenge_marker_is_not_a_block() {
1493 let body = r#"{"id":"b1","items":[{"id":"1","name":{"finnish":"Just a moment"}}]}"#;
1494 let v = classify(raw(200, body, JSON, None)).expect("a 200 must stay a success");
1495 assert_eq!(v["id"], "b1");
1496 }
1497
1498 #[test]
1501 fn html_404_is_not_a_cloudflare_block() {
1502 let html = "<!DOCTYPE html><html><body>Not found</body></html>";
1503 assert!(matches!(
1504 classify(raw(404, html, "text/html", None)),
1505 Err(ApiError::Api { status: 404, .. })
1506 ));
1507 }
1508
1509 #[test]
1511 fn json_error_is_an_api_error_not_a_block() {
1512 let body = r#"{"error":{"message":"Basket not found"}}"#;
1513 match classify(raw(404, body, JSON, None)) {
1514 Err(ApiError::Api { status, message }) => {
1515 assert_eq!(status, 404);
1516 assert_eq!(message, "Basket not found");
1517 }
1518 other => panic!("expected Api, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1526 fn a_challenge_is_distinguished_from_a_refusal() {
1527 assert!(first_marker("Just a moment...", CHALLENGE_MARKERS).is_some());
1528 assert!(first_marker("Just a moment...", BLOCK_MARKERS).is_none());
1529
1530 assert!(first_marker("Pyyntö estetty (CF/WB)", BLOCK_MARKERS).is_some());
1531 assert!(first_marker("Pyyntö estetty (CF/WB)", CHALLENGE_MARKERS).is_none());
1532
1533 assert!(cloudflare_marker("Just a moment...").is_some());
1535 assert!(cloudflare_marker("Pyyntö estetty (CF/WB)").is_some());
1536 assert!(cloudflare_marker("Tuotteet Kaupat Reseptit Ostoskori").is_none());
1538 }
1539
1540 fn cf() -> ApiError {
1541 ApiError::Cloudflare {
1542 detail: "blocked".into(),
1543 }
1544 }
1545
1546 fn browser_gone() -> ApiError {
1547 ApiError::BrowserGone {
1548 detail: "connection reset".into(),
1549 }
1550 }
1551
1552 #[test]
1554 fn a_cloudflare_block_is_relaunched_once_then_given_up_on() {
1555 assert_eq!(plan_recovery(&cf(), false, false), Recovery::Relaunch);
1556 assert_eq!(plan_recovery(&cf(), true, false), Recovery::GiveUp);
1557 }
1558
1559 #[test]
1561 fn a_dead_browser_is_relaunched_once_then_given_up_on() {
1562 assert_eq!(
1563 plan_recovery(&browser_gone(), false, false),
1564 Recovery::Relaunch
1565 );
1566 assert_eq!(
1567 plan_recovery(&browser_gone(), true, false),
1568 Recovery::GiveUp
1569 );
1570 }
1571
1572 #[test]
1574 fn a_dead_transport_is_reported_as_browser_gone() {
1575 let io_err = CdpError::Io(std::io::Error::other("connection reset"));
1576 assert!(matches!(
1577 cdp_error_to_api_error("page evaluation", io_err),
1578 ApiError::BrowserGone { .. }
1579 ));
1580 }
1581
1582 #[tokio::test]
1584 async fn a_canceled_response_channel_is_reported_as_browser_gone() {
1585 let (tx, rx) = futures::channel::oneshot::channel::<()>();
1586 drop(tx);
1587 let canceled = rx.await.unwrap_err();
1588 let e = CdpError::ChannelSendError(chromiumoxide::error::ChannelError::Canceled(canceled));
1589 assert!(matches!(
1590 cdp_error_to_api_error("page evaluation", e),
1591 ApiError::BrowserGone { .. }
1592 ));
1593 }
1594
1595 #[test]
1597 fn a_non_transport_cdp_error_stays_the_opaque_other() {
1598 assert!(matches!(
1599 cdp_error_to_api_error("page evaluation", CdpError::NotFound),
1600 ApiError::Other(_)
1601 ));
1602 assert!(matches!(
1603 cdp_error_to_api_error("page evaluation", CdpError::Timeout),
1604 ApiError::Other(_)
1605 ));
1606 }
1607
1608 #[test]
1609 fn a_stale_build_is_refreshed_once_then_given_up_on() {
1610 let stale = ApiError::StaleBuild {
1611 wanted: Some("31844".into()),
1612 };
1613 assert_eq!(
1614 plan_recovery(&stale, false, false),
1615 Recovery::RefreshBuild("31844".into())
1616 );
1617 assert_eq!(plan_recovery(&stale, false, true), Recovery::GiveUp);
1618 }
1619
1620 #[test]
1625 fn a_stale_build_carrying_no_value_is_not_retryable() {
1626 let e = ApiError::StaleBuild { wanted: None };
1627 assert_eq!(plan_recovery(&e, false, false), Recovery::GiveUp);
1628 }
1629
1630 #[tokio::test]
1633 async fn the_first_request_is_not_delayed() {
1634 let limiter = RateLimiter::new(Duration::from_millis(500));
1635 let start = Instant::now();
1636 limiter.acquire().await;
1637 assert!(
1638 start.elapsed() < Duration::from_millis(100),
1639 "{:?}",
1640 start.elapsed()
1641 );
1642 }
1643
1644 #[tokio::test]
1647 async fn concurrent_callers_are_spaced_out_not_batched() {
1648 let limiter = std::sync::Arc::new(RateLimiter::new(Duration::from_millis(50)));
1649 let start = Instant::now();
1650 let mut handles = Vec::new();
1651 for _ in 0..4 {
1652 let limiter = std::sync::Arc::clone(&limiter);
1653 handles.push(tokio::spawn(async move { limiter.acquire().await }));
1654 }
1655 for handle in handles {
1656 handle.await.unwrap();
1657 }
1658 assert!(
1660 start.elapsed() >= Duration::from_millis(150),
1661 "went out as a burst: {:?}",
1662 start.elapsed()
1663 );
1664 }
1665
1666 #[tokio::test]
1668 async fn a_zero_interval_disables_the_limiter() {
1669 let limiter = RateLimiter::new(Duration::ZERO);
1670 let start = Instant::now();
1671 for _ in 0..20 {
1672 limiter.acquire().await;
1673 }
1674 assert!(
1675 start.elapsed() < Duration::from_millis(100),
1676 "{:?}",
1677 start.elapsed()
1678 );
1679 }
1680
1681 #[test]
1682 fn a_shutdown_request_interrupts_a_still_open_challenge() {
1683 assert_eq!(
1684 clearance_step("https://example.com", Some("Just a moment..."), true, false),
1685 ClearanceStep::ShuttingDown,
1686 "without this, a shutdown mid-challenge would fall through to KeepWaiting \
1687 and hold the live lock for up to CLEARANCE_TIMEOUT"
1688 );
1689 }
1690
1691 #[test]
1692 fn shutting_down_does_not_override_an_already_cleared_page() {
1693 assert_eq!(
1694 clearance_step(SHOP_ORIGIN, Some("Tervetuloa K-Ruokaan"), true, false),
1695 ClearanceStep::Ready
1696 );
1697 }
1698
1699 #[test]
1700 fn a_block_marker_still_refuses_during_a_shutdown() {
1701 assert_eq!(
1702 clearance_step(
1703 "https://example.com",
1704 Some("Attention Required"),
1705 true,
1706 false
1707 ),
1708 ClearanceStep::Refused("Attention Required")
1709 );
1710 }
1711
1712 #[test]
1713 fn a_past_deadline_still_times_out_without_a_shutdown_request() {
1714 assert_eq!(
1715 clearance_step("https://example.com", Some("Just a moment..."), false, true),
1716 ClearanceStep::TimedOut
1717 );
1718 }
1719
1720 #[test]
1721 fn keeps_waiting_when_nothing_terminal_has_happened() {
1722 assert_eq!(
1723 clearance_step(
1724 "https://example.com",
1725 Some("Just a moment..."),
1726 false,
1727 false
1728 ),
1729 ClearanceStep::KeepWaiting
1730 );
1731 }
1732
1733 #[tokio::test]
1735 async fn close_signals_shutdown_before_it_can_get_the_lock() {
1736 let profile =
1737 std::env::temp_dir().join(format!("k-ruoka-shutdown-flag-test-{}", std::process::id()));
1738 let _ = std::fs::remove_dir_all(&profile);
1739 let session = std::sync::Arc::new(Session::new(&profile, LaunchMode::Headless).unwrap());
1740
1741 let guard = session.live.lock().await;
1742 let closing = {
1743 let session = std::sync::Arc::clone(&session);
1744 tokio::spawn(async move { session.close().await })
1745 };
1746
1747 let deadline = Instant::now() + Duration::from_secs(5);
1748 while !session.shutting_down.load(Ordering::Relaxed) {
1749 assert!(
1750 Instant::now() < deadline,
1751 "close did not set shutting_down while this test still held the live lock"
1752 );
1753 tokio::time::sleep(Duration::from_millis(5)).await;
1754 }
1755
1756 drop(guard);
1757 closing.await.unwrap().unwrap();
1758 let _ = std::fs::remove_dir_all(&profile);
1759 }
1760
1761 #[tokio::test]
1762 async fn idle_close_is_a_noop_without_a_live_browser() {
1763 let profile =
1764 std::env::temp_dir().join(format!("k-ruoka-idle-close-test-{}", std::process::id()));
1765 let _ = std::fs::remove_dir_all(&profile);
1766 let session = Session::new(&profile, LaunchMode::Headless).unwrap();
1767
1768 tokio::time::sleep(Duration::from_millis(5)).await;
1769 assert!(
1770 !session
1771 .close_if_idle(Duration::from_millis(1))
1772 .await
1773 .unwrap()
1774 );
1775
1776 let _ = std::fs::remove_dir_all(&profile);
1777 }
1778
1779 #[tokio::test]
1780 async fn idle_close_waits_while_an_operation_is_in_flight() {
1781 let profile =
1782 std::env::temp_dir().join(format!("k-ruoka-idle-activity-test-{}", std::process::id()));
1783 let _ = std::fs::remove_dir_all(&profile);
1784 let session = Session::new(&profile, LaunchMode::Headless).unwrap();
1785
1786 let activity = session.begin_browser_activity().await;
1787 tokio::time::sleep(Duration::from_millis(5)).await;
1788 assert!(
1789 !session
1790 .close_if_idle(Duration::from_millis(1))
1791 .await
1792 .unwrap()
1793 );
1794 drop(activity);
1795
1796 let _ = std::fs::remove_dir_all(&profile);
1797 }
1798
1799 #[test]
1802 fn only_headless_may_relaunch_out_from_under_the_browser() {
1803 assert!(!relaunch_costs_a_human_their_login(LaunchMode::Headless));
1804 assert!(relaunch_costs_a_human_their_login(LaunchMode::Headful {
1805 debug_port: 9222
1806 }));
1807
1808 let block = ApiError::Cloudflare {
1810 detail: "page load rejected".into(),
1811 };
1812 assert_eq!(plan_recovery(&block, false, false), Recovery::Relaunch);
1813 assert_eq!(plan_recovery(&block, true, false), Recovery::GiveUp);
1814 }
1815
1816 #[test]
1818 fn only_the_browser_that_got_blocked_is_replaced() {
1819 assert!(should_replace(Some(7), Some(7)));
1821 assert!(!should_replace(Some(8), Some(7)));
1824 assert!(should_replace(None, Some(7)));
1826 assert!(should_replace(None, None));
1827 }
1828
1829 #[test]
1840 fn no_browser_to_blame_is_not_the_same_as_generation_zero() {
1841 assert!(!should_replace(Some(1), Some(0)));
1844 assert!(should_replace(None, None));
1847 assert!(should_replace(Some(0), Some(0)));
1849 }
1850
1851 #[test]
1854 fn auth_expiry_and_plain_api_errors_are_never_retried() {
1855 assert_eq!(
1856 plan_recovery(&ApiError::AuthExpired, false, false),
1857 Recovery::GiveUp
1858 );
1859 assert_eq!(
1860 plan_recovery(
1861 &ApiError::Api {
1862 status: 404,
1863 message: "nope".into()
1864 },
1865 false,
1866 false
1867 ),
1868 Recovery::GiveUp
1869 );
1870 assert_eq!(
1871 plan_recovery(&ApiError::InvalidRequest("bad".into()), false, false),
1872 Recovery::GiveUp
1873 );
1874 }
1875
1876 #[test]
1877 fn fetch_script_escapes_its_inputs() {
1878 let s = fetch_script("POST", "/kr-api/basket/active", None, Some("31844"));
1879 assert!(s.contains(r#"const build = "31844""#));
1880 assert!(s.contains(r#"fetch("/kr-api/basket/active""#));
1881 assert!(s.contains("const body = undefined"));
1882 }
1883
1884 #[test]
1888 fn fetch_script_escapes_the_body() {
1889 let hostile = r#"a"); alert('x'); //\"#;
1890 let body = serde_json::json!([{ "type": "REMOVE-ITEM", "itemId": hostile }]);
1891 let s = fetch_script("PATCH", "/kr-api/basket/by-id/1", Some(&body), None);
1892
1893 assert!(
1895 !s.contains(r#"itemId":"a");"#),
1896 "raw injection present:\n{s}"
1897 );
1898 assert!(
1899 s.contains(r#"\"); alert('x'); //\\"#),
1900 "not escaped as expected:\n{s}"
1901 );
1902
1903 let line = s
1905 .lines()
1906 .find(|l| l.contains("JSON.stringify("))
1907 .expect("the body line");
1908 let json = line
1909 .trim()
1910 .trim_start_matches("const body = JSON.stringify(")
1911 .trim_end_matches(';')
1912 .trim_end_matches(')');
1913 let parsed: serde_json::Value = serde_json::from_str(json).expect("valid JSON literal");
1914 assert_eq!(parsed[0]["itemId"], hostile);
1915 }
1916
1917 #[test]
1919 fn only_urls_actually_on_the_shop_origin_pass() {
1920 let cases: &[(&str, bool)] = &[
1921 ("https://www.k-ruoka.fi", true),
1922 (SHOP_URL, true),
1923 ("https://www.k-ruoka.fi/kauppa?x=1", true),
1924 ("https://www.k-ruoka.fi/kauppa#section", true),
1925 ("https://www.k-ruoka.fi.example.net/kauppa", false),
1926 ("https://www.k-ruoka.film/", false),
1927 ("https://www.k-ruoka.fi:9222/", false),
1928 ("https://www.k-ruoka.fi@evil.example/kauppa", false),
1929 (
1930 "https://login.kesko.fi/?redirect=https://www.k-ruoka.fi",
1931 false,
1932 ),
1933 ("http://www.k-ruoka.fi", false),
1934 ];
1935 for (url, expected) in cases {
1936 assert_eq!(on_shop_origin(url), *expected, "url: {url}");
1937 }
1938 }
1939}