1use super::{
2 BodyCaptureStream, HttpRequestCapture, HttpResponseCapture, LogMetaInfo, Recorder,
3 RecorderSession, StreamingRecorder, WebSocketCapture, WebSocketCaptureCloseHandle,
4 WebSocketCaptureRecorder,
5};
6use crate::layer::har::spec;
7use crate::{BodyCaptureEvent, CaptureOutcome};
8use base64::engine::general_purpose::STANDARD as BASE64;
9use jiff::Timestamp;
10use parking_lot::Mutex;
11use rama_core::error::{BoxError, ErrorContext};
12use rama_core::extensions::{Extension, Extensions};
13use rama_core::telemetry::tracing;
14use rama_utils::{
15 fs::{CreatedFilePermissions, OpenOptions, OpenOptionsSync, safe_open, safe_open_sync},
16 time::now_unix,
17};
18use serde_json::Value;
19use std::collections::BTreeMap;
20use std::ffi::OsString;
21use std::io::{BufRead, Read, SeekFrom, Write};
22use std::ops::Deref;
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Once};
25use tokio::fs::File;
26use tokio::io::{AsyncSeekExt, AsyncWriteExt, BufWriter};
27use tokio::sync::{mpsc, oneshot, watch};
28use tokio::task::JoinSet;
29use tokio::time::Instant;
30use uuid::Uuid;
31
32#[derive(Debug)]
33struct TempPath {
34 path: PathBuf,
35 cleanup: TempPathCleanup,
36}
37
38#[derive(Debug, Clone)]
39struct TempPathCleanup(mpsc::UnboundedSender<TempPathCleanupMessage>);
40
41#[derive(Debug)]
42enum TempPathCleanupMessage {
43 Remove(PathBuf),
44 Flush(oneshot::Sender<()>),
45}
46
47impl TempPathCleanup {
48 fn new() -> (Self, mpsc::UnboundedReceiver<TempPathCleanupMessage>) {
49 let (tx, rx) = mpsc::unbounded_channel();
50 (Self(tx), rx)
51 }
52
53 async fn flush(&self) {
54 let (tx, rx) = oneshot::channel();
55 if self.0.send(TempPathCleanupMessage::Flush(tx)).is_ok() {
56 _ = rx.await;
57 }
58 }
59}
60
61impl TempPath {
62 fn new(path: PathBuf, cleanup: TempPathCleanup) -> Self {
63 Self { path, cleanup }
64 }
65}
66
67impl AsRef<Path> for TempPath {
68 fn as_ref(&self) -> &Path {
69 &self.path
70 }
71}
72
73impl Deref for TempPath {
74 type Target = Path;
75
76 fn deref(&self) -> &Self::Target {
77 &self.path
78 }
79}
80
81impl Drop for TempPath {
82 fn drop(&mut self) {
83 let path = std::mem::take(&mut self.path);
84 if self
85 .cleanup
86 .0
87 .send(TempPathCleanupMessage::Remove(path.clone()))
88 .is_err()
89 {
90 tracing::debug!(?path, "temporary HAR cleanup worker is unavailable");
91 }
92 }
93}
94
95async fn remove_temp_paths(mut rx: mpsc::UnboundedReceiver<TempPathCleanupMessage>) {
96 while let Some(message) = rx.recv().await {
97 match message {
98 TempPathCleanupMessage::Remove(path) => {
99 if let Err(err) = tokio::fs::remove_file(&path).await
100 && err.kind() != std::io::ErrorKind::NotFound
101 {
102 tracing::debug!(?path, "failed to remove temporary HAR artifact: {err}");
103 }
104 }
105 TempPathCleanupMessage::Flush(done) => {
106 _ = done.send(());
107 }
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
118pub struct FileRecorder {
119 tx: mpsc::Sender<FileRecorderMessage>,
120 task: Arc<FileRecorderTaskStarter>,
121}
122
123#[derive(Debug)]
124struct FileRecorderTaskStarter {
125 once: Once,
126 task: Mutex<Option<FileRecorderTask>>,
127}
128
129#[derive(Debug)]
130pub struct FileRecorderSession {
131 tx: mpsc::Sender<CaptureWorkerMessage>,
132 path: HarFilePath,
133 web_socket_capture: Option<WebSocketCapture>,
134}
135
136#[derive(Debug, Clone, Extension)]
137#[extension(tags(http))]
138pub struct HarFilePath(Arc<PathBuf>);
143
144impl AsRef<Path> for HarFilePath {
145 fn as_ref(&self) -> &Path {
146 self.0.as_ref()
147 }
148}
149
150impl Deref for HarFilePath {
151 type Target = Path;
152
153 fn deref(&self) -> &Self::Target {
154 self.0.as_ref()
155 }
156}
157
158#[derive(Debug)]
159enum FileRecorderMessage {
160 StartHttp {
161 request: Box<HttpRequestCapture>,
162 reply: oneshot::Sender<Option<FileRecorderSession>>,
163 },
164 Record {
165 log: Box<spec::Log>,
166 reply: oneshot::Sender<Extensions>,
167 },
168 Stop {
169 done: oneshot::Sender<()>,
170 },
171}
172
173#[derive(Debug)]
174enum CaptureWorkerMessage {
175 Response {
176 response: Box<HttpResponseCapture>,
177 headers_at: Instant,
178 },
179 RequestOnly,
180}
181
182#[derive(Debug)]
183struct FileRecorderTask {
184 rx: mpsc::Receiver<FileRecorderMessage>,
185 dir: PathBuf,
186 output: FileRecorderOutput,
187 start: Instant,
188 start_epoch: i64,
189 log_meta_info: LogMetaInfo,
190}
191
192type CaptureWorkerResult = (u64, Result<TempPath, BoxError>);
193
194#[derive(Debug)]
195enum FileRecorderOutput {
196 Generated { prefix: String },
197 Exact { file_name: OsString },
198}
199
200#[derive(Debug)]
201struct Storage {
202 file: File,
203 path: PathBuf,
204 has_entries: bool,
205 valid_position: u64,
206 valid: bool,
207}
208
209impl Storage {
210 async fn try_new(path: PathBuf, log: &spec::Log) -> Result<Self, BoxError> {
211 let parent = path
212 .parent()
213 .ok_or_else(|| std::io::Error::other("HAR file path has no parent"))?;
214 let file_name = path
215 .file_name()
216 .ok_or_else(|| std::io::Error::other("HAR file path has no file name"))?;
217 create_har_parent_dir(parent)
218 .await
219 .context("create HAR file parent dir")?;
220 let mut file = OpenOptions::new()
223 .write(true)
224 .create(true)
225 .truncate(true)
226 .created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
227 .jail(parent)
228 .open(file_name)
229 .await
230 .context("create HAR file")?;
231
232 let mut header = Vec::new();
233 serde_json::to_writer(
234 &mut header,
235 &serde_json::json!({
236 "log": {
237 "version": log.version,
238 "creator": log.creator,
239 "browser": log.browser,
240 "comment": log.comment,
241 "pages": [],
242 },
243 }),
244 )
245 .context("serialize HAR header")?;
246 header.truncate(header.len() - 2);
247 header.extend_from_slice(b",\"entries\":[");
248 file.write_all(&header).await.context("write HAR header")?;
249
250 Ok(Self {
251 file,
252 path,
253 has_entries: false,
254 valid_position: u64::try_from(header.len()).unwrap_or(u64::MAX),
255 valid: true,
256 })
257 }
258
259 async fn append_artifact(&mut self, path: &Path) -> Result<(), BoxError> {
260 let mut artifact = safe_open(path)
261 .await
262 .context("open completed HAR entry artifact")?;
263 let checkpoint = self.valid_position;
264 self.valid = false;
265 let result = async {
266 let separator_len = if self.has_entries {
267 self.file
268 .write_u8(b',')
269 .await
270 .context("write HAR entry separator")?;
271 1
272 } else {
273 0
274 };
275 let copied = tokio::io::copy(&mut artifact, &mut self.file)
276 .await
277 .context("copy completed HAR entry artifact")?;
278 Ok::<_, BoxError>((separator_len, copied))
279 }
280 .await;
281
282 match result {
283 Ok((separator_len, copied)) => {
284 self.valid_position = checkpoint
285 .saturating_add(separator_len)
286 .saturating_add(copied);
287 self.has_entries = true;
288 self.valid = true;
289 Ok(())
290 }
291 Err(err) => {
292 if let Err(rollback_err) = self.rollback(checkpoint).await {
293 return Err(rollback_err)
294 .context_field("append_error", err)
295 .context("rollback failed after appending HAR artifact");
296 }
297 self.valid = true;
298 Err(err)
299 }
300 }
301 }
302
303 async fn rollback(&mut self, position: u64) -> std::io::Result<()> {
304 self.file.set_len(position).await?;
305 self.file.seek(SeekFrom::Start(position)).await?;
306 Ok(())
307 }
308}
309
310impl FileRecorderTask {
311 fn new(
312 rx: mpsc::Receiver<FileRecorderMessage>,
313 dir: PathBuf,
314 output: FileRecorderOutput,
315 log_meta_info: LogMetaInfo,
316 ) -> Self {
317 Self {
318 rx,
319 dir,
320 output,
321 start: Instant::now(),
322 start_epoch: now_unix(),
323 log_meta_info,
324 }
325 }
326
327 async fn run(mut self) {
328 let mut storage = None;
329 let mut counter = 0_u64;
330 let mut workers: JoinSet<CaptureWorkerResult> = JoinSet::new();
331 let mut next_sequence = 0_u64;
332 let mut next_sequence_to_write = 0_u64;
333 let mut completed = BTreeMap::new();
334 let (cancel_tx, _) = watch::channel(false);
335 let (temp_cleanup, temp_cleanup_rx) = TempPathCleanup::new();
336 let temp_cleanup_task = tokio::spawn(remove_temp_paths(temp_cleanup_rx));
337
338 loop {
339 tokio::select! {
340 worker = workers.join_next(), if !workers.is_empty() => {
341 if handle_worker(
342 worker,
343 &mut storage,
344 &mut completed,
345 &mut next_sequence_to_write,
346 ).await {
347 reset_failed_generation(
348 &cancel_tx,
349 &mut workers,
350 &mut storage,
351 &mut completed,
352 next_sequence,
353 &mut next_sequence_to_write,
354 ).await;
355 }
356 }
357 message = self.rx.recv() => {
358 let Some(message) = message else {
359 break;
360 };
361 match message {
362 FileRecorderMessage::StartHttp { request, reply } => {
363 let log: spec::Log = self.log_meta_info.clone().into();
364 let result = match self
365 .ensure_storage(&mut storage, &mut counter, &log)
366 .await
367 {
368 Ok(storage_ref) => {
369 let path = HarFilePath(Arc::new(storage_ref.path.clone()));
370 let (tx, rx) = mpsc::channel(1);
371 let web_socket = if request.web_socket {
372 match create_web_socket_capture(
373 self.dir.clone(),
374 temp_cleanup.clone(),
375 )
376 .await
377 {
378 Ok(capture) => Some(capture),
379 Err(err) => {
380 tracing::debug!(
381 "failed to create WebSocket HAR capture: {err}"
382 );
383 _ = reply.send(None);
384 continue;
385 }
386 }
387 } else {
388 None
389 };
390 let web_socket_capture = web_socket
391 .as_ref()
392 .map(|(capture, _, _)| capture.clone());
393 let web_socket = web_socket.map(
394 |(_capture, completion, closer)| (closer, completion),
395 );
396 let sequence = next_sequence;
397 next_sequence = next_sequence.saturating_add(1);
398 let dir = self.dir.clone();
399 let temp_cleanup = temp_cleanup.clone();
400 let cancel = cancel_tx.subscribe();
401 workers.spawn(async move {
402 (
403 sequence,
404 capture_http_entry(
405 *request,
406 rx,
407 dir,
408 temp_cleanup,
409 cancel,
410 web_socket,
411 )
412 .await,
413 )
414 });
415 Some(FileRecorderSession {
416 tx,
417 path,
418 web_socket_capture,
419 })
420 }
421 Err(err) => {
422 tracing::debug!(
423 "failed to create storage for HAR recording: {err}"
424 );
425 None
426 }
427 };
428 if reply.send(result).is_err() {
429 tracing::debug!("HAR recording caller dropped its start reply");
430 }
431 }
432 FileRecorderMessage::Record { log, reply } => {
433 let result = self
434 .record_materialized(
435 &mut storage,
436 &mut counter,
437 *log,
438 temp_cleanup.clone(),
439 )
440 .await;
441 match result {
442 Ok(path) => {
443 let extensions = Extensions::new();
444 extensions.insert(HarFilePath(Arc::new(path)));
445 _ = reply.send(extensions);
446 }
447 Err(err) => {
448 tracing::debug!("failed to record materialized HAR log: {err}");
449 reset_failed_generation(
450 &cancel_tx,
451 &mut workers,
452 &mut storage,
453 &mut completed,
454 next_sequence,
455 &mut next_sequence_to_write,
456 ).await;
457 }
458 }
459 }
460 FileRecorderMessage::Stop { done } => {
461 cancel_tx.send_replace(true);
462 while let Some(worker) = workers.join_next().await {
463 if handle_worker(
464 Some(worker),
465 &mut storage,
466 &mut completed,
467 &mut next_sequence_to_write,
468 ).await
469 && let Some(storage) = storage.take()
470 {
471 finish_storage(storage).await;
472 }
473 }
474 if let Some(storage) = storage.take() {
475 finish_storage(storage).await;
476 }
477 completed.clear();
478 next_sequence_to_write = next_sequence;
479 cancel_tx.send_replace(false);
480 temp_cleanup.flush().await;
481 _ = done.send(());
482 }
483 }
484 }
485 }
486 }
487
488 cancel_tx.send_replace(true);
489 while let Some(worker) = workers.join_next().await {
490 if handle_worker(
491 Some(worker),
492 &mut storage,
493 &mut completed,
494 &mut next_sequence_to_write,
495 )
496 .await
497 && let Some(storage) = storage.take()
498 {
499 finish_storage(storage).await;
500 }
501 }
502 if let Some(storage) = storage {
503 finish_storage(storage).await;
504 }
505 completed.clear();
506 temp_cleanup.flush().await;
507 drop(temp_cleanup);
508 _ = temp_cleanup_task.await;
509 }
510
511 async fn ensure_storage<'a>(
512 &self,
513 storage: &'a mut Option<Storage>,
514 counter: &mut u64,
515 log: &spec::Log,
516 ) -> Result<&'a mut Storage, BoxError> {
517 if storage.is_none() {
518 create_har_parent_dir(&self.dir)
519 .await
520 .context("create HAR recording dir")?;
521 let file_name = match &self.output {
522 FileRecorderOutput::Generated { prefix } => {
523 let file_name = format!(
524 "{}_{}_{}_{}.har",
525 prefix,
526 self.start_epoch,
527 *counter,
528 self.start.elapsed().as_secs()
529 );
530 *counter = counter.saturating_add(1);
531 file_name.into()
532 }
533 FileRecorderOutput::Exact { file_name } => file_name.clone(),
534 };
535 let path = rama_utils::fs::safe_path_in(&self.dir, file_name)
536 .await
537 .context("validate HAR file path")?;
538 *storage = Some(Storage::try_new(path, log).await?);
539 }
540 storage
541 .as_mut()
542 .ok_or_else(|| std::io::Error::other("HAR storage was not initialized").into())
543 }
544
545 async fn record_materialized(
546 &self,
547 storage: &mut Option<Storage>,
548 counter: &mut u64,
549 log: spec::Log,
550 temp_cleanup: TempPathCleanup,
551 ) -> Result<PathBuf, BoxError> {
552 if log.pages.as_ref().is_some_and(|pages| !pages.is_empty()) {
553 tracing::debug!("HAR pages are not supported by the file recorder");
554 }
555 let storage = self.ensure_storage(storage, counter, &log).await?;
556 for entry in log.entries {
557 let artifact =
558 serialize_materialized_entry(self.dir.clone(), entry, temp_cleanup.clone()).await?;
559 storage.append_artifact(&artifact).await?;
560 }
561 Ok(storage.path.clone())
562 }
563}
564
565async fn serialize_materialized_entry(
566 dir: PathBuf,
567 entry: spec::Entry,
568 temp_cleanup: TempPathCleanup,
569) -> Result<TempPath, BoxError> {
570 tokio::task::spawn_blocking(move || {
571 let (path, mut file) = create_temp_file_sync(&dir, "entry", temp_cleanup)
572 .context("create private materialized HAR entry artifact")?;
573 serde_json::to_writer(&mut file, &entry).context("serialize materialized HAR entry")?;
574 file.flush()
575 .context("flush materialized HAR entry artifact")?;
576 Ok(path)
577 })
578 .await
579 .context("join materialized HAR entry serialization task")?
580}
581
582async fn handle_worker(
583 worker: Option<Result<CaptureWorkerResult, tokio::task::JoinError>>,
584 storage: &mut Option<Storage>,
585 completed: &mut BTreeMap<u64, Option<TempPath>>,
586 next_sequence_to_write: &mut u64,
587) -> bool {
588 let Some(worker) = worker else {
589 return false;
590 };
591 let (sequence, artifact) = match worker {
592 Ok((sequence, Ok(artifact))) => (sequence, Some(artifact)),
593 Ok((sequence, Err(err))) => {
594 tracing::debug!("failed to capture streaming HAR entry: {err}");
595 (sequence, None)
596 }
597 Err(err) => {
598 tracing::debug!("streaming HAR entry task failed: {err}");
599 return true;
602 }
603 };
604
605 if completed.insert(sequence, artifact).is_some() {
606 tracing::debug!(sequence, "duplicate streaming HAR entry sequence");
607 return true;
608 }
609
610 while let Some(artifact) = completed.remove(next_sequence_to_write) {
611 *next_sequence_to_write = next_sequence_to_write.saturating_add(1);
612 let Some(artifact) = artifact else {
613 continue;
614 };
615 let Some(storage_ref) = storage.as_mut() else {
616 tracing::debug!("discard streaming HAR artifact without active storage");
617 continue;
618 };
619 if let Err(err) = storage_ref.append_artifact(&artifact).await {
620 tracing::debug!("failed to append streaming HAR entry: {err}");
621 return true;
622 }
623 }
624 false
625}
626
627async fn reset_failed_generation(
628 cancel: &watch::Sender<bool>,
629 workers: &mut JoinSet<CaptureWorkerResult>,
630 storage: &mut Option<Storage>,
631 completed: &mut BTreeMap<u64, Option<TempPath>>,
632 next_sequence: u64,
633 next_sequence_to_write: &mut u64,
634) {
635 cancel.send_replace(true);
636 if let Some(storage) = storage.take() {
637 finish_storage(storage).await;
638 }
639 while workers.join_next().await.is_some() {}
640 completed.clear();
641 *next_sequence_to_write = next_sequence;
642 cancel.send_replace(false);
643}
644
645#[derive(Debug)]
646struct BodyArtifact {
647 path: TempPath,
648 size: i64,
649 outcome: CaptureOutcome,
650 finished_at: Instant,
651}
652
653#[derive(Debug)]
654struct WebSocketArtifact {
655 path: TempPath,
656 last_activity_at: Option<Instant>,
657 closed_at: Instant,
658}
659
660type WebSocketCaptureCompletion = oneshot::Receiver<Result<WebSocketArtifact, BoxError>>;
661
662struct RecordedWebSocketMessage {
663 message: spec::WebSocketMessage,
664 observed_at: Instant,
665}
666
667struct FileWebSocketCaptureRecorder {
668 sender: mpsc::Sender<RecordedWebSocketMessage>,
669}
670
671impl WebSocketCaptureRecorder for FileWebSocketCaptureRecorder {
672 async fn record(&self, message: spec::WebSocketMessage) -> Result<(), BoxError> {
673 self.sender
674 .send(RecordedWebSocketMessage {
675 message,
676 observed_at: Instant::now(),
677 })
678 .await
679 .map_err(|err| std::io::Error::other(err).into())
680 }
681}
682
683async fn create_web_socket_capture(
684 dir: PathBuf,
685 temp_cleanup: TempPathCleanup,
686) -> Result<
687 (
688 WebSocketCapture,
689 WebSocketCaptureCompletion,
690 WebSocketCaptureCloseHandle,
691 ),
692 BoxError,
693> {
694 let (path, file) = create_temp_file(dir, "websocket", temp_cleanup).await?;
695 let (sender, receiver) = mpsc::channel(1);
696 let (closed_at, closed_at_rx) = watch::channel(None);
697 let capture = WebSocketCapture::new(FileWebSocketCaptureRecorder { sender }, {
698 move || {
699 closed_at.send_replace(Some(Instant::now()));
700 }
701 });
702 let closer = capture.close_handle();
703 let (done, completion) = oneshot::channel();
704 tokio::spawn(async move {
705 _ = done.send(write_web_socket_capture(file, path, receiver, closed_at_rx).await);
706 });
707 Ok((capture, completion, closer))
708}
709
710async fn write_web_socket_capture(
711 file: File,
712 path: TempPath,
713 mut receiver: mpsc::Receiver<RecordedWebSocketMessage>,
714 mut closed_at: watch::Receiver<Option<Instant>>,
715) -> Result<WebSocketArtifact, BoxError> {
716 let mut writer = BufWriter::new(file);
717 let mut has_messages = false;
718 let mut cancelled = false;
719 let mut last_activity_at = None;
720 loop {
721 let message = if cancelled {
722 receiver.recv().await
723 } else {
724 tokio::select! {
725 message = receiver.recv() => message,
726 changed = closed_at.changed() => {
727 if changed.is_err() || closed_at.borrow().is_some() {
728 receiver.close();
729 cancelled = true;
730 }
731 continue;
732 }
733 }
734 };
735 let Some(message) = message else {
736 break;
737 };
738 if has_messages {
739 writer
740 .write_all(b",")
741 .await
742 .context("write WebSocket HAR separator")?;
743 }
744 let encoded =
745 serde_json::to_vec(&message.message).context("serialize WebSocket HAR message")?;
746 writer
747 .write_all(&encoded)
748 .await
749 .context("write WebSocket HAR message")?;
750 has_messages = true;
751 last_activity_at = Some(message.observed_at);
752 }
753 writer
754 .flush()
755 .await
756 .context("flush WebSocket HAR artifact")?;
757 drop(writer);
758 Ok(WebSocketArtifact {
759 path,
760 last_activity_at,
761 closed_at: closed_at.borrow().unwrap_or_else(Instant::now),
762 })
763}
764
765async fn capture_http_entry(
766 request: HttpRequestCapture,
767 mut rx: mpsc::Receiver<CaptureWorkerMessage>,
768 dir: PathBuf,
769 temp_cleanup: TempPathCleanup,
770 mut cancel: watch::Receiver<bool>,
771 web_socket: Option<(WebSocketCaptureCloseHandle, WebSocketCaptureCompletion)>,
772) -> Result<TempPath, BoxError> {
773 let HttpRequestCapture {
774 started_date_time,
775 begin,
776 request,
777 body_mime_type,
778 body: request_body,
779 web_socket: _,
780 } = request;
781 let request_capture = spool_body(
782 request_body,
783 dir.clone(),
784 temp_cleanup.clone(),
785 cancel.clone(),
786 );
787 tokio::pin!(request_capture);
788 let mut request_artifact = None;
789
790 let command = loop {
791 tokio::select! {
792 biased;
793 result = &mut request_capture, if request_artifact.is_none() => {
794 request_artifact = Some(result?);
795 }
796 command = rx.recv() => break command.unwrap_or(CaptureWorkerMessage::RequestOnly),
797 changed = cancel.changed() => {
798 if changed.is_err() || *cancel.borrow() {
799 break CaptureWorkerMessage::RequestOnly;
800 }
801 }
802 }
803 };
804
805 let is_web_socket = web_socket.is_some();
806 let (request_artifact, response, mut completed_at) = match command {
807 CaptureWorkerMessage::Response {
808 response,
809 headers_at,
810 } => {
811 let HttpResponseCapture { response, body } = *response;
812 let response_capture =
813 spool_body(body, dir.clone(), temp_cleanup.clone(), cancel.clone());
814 let (request_artifact, response_artifact) =
815 if let Some(request_artifact) = request_artifact {
816 let response_artifact = response_capture.await?;
817 (request_artifact, response_artifact)
818 } else {
819 let (request_artifact, response_artifact) =
820 tokio::join!(&mut request_capture, response_capture);
821 (request_artifact?, response_artifact?)
822 };
823 let completed_at = if is_web_socket {
824 headers_at
825 } else {
826 request_artifact
827 .finished_at
828 .max(response_artifact.finished_at)
829 };
830 (
831 request_artifact,
832 Some((response, response_artifact)),
833 completed_at,
834 )
835 }
836 CaptureWorkerMessage::RequestOnly => {
837 let request_artifact = match request_artifact {
838 Some(artifact) => artifact,
839 None => request_capture.await?,
840 };
841 let completed_at = request_artifact.finished_at;
842 (request_artifact, None, completed_at)
843 }
844 };
845
846 let web_socket = match web_socket {
847 Some((closer, completion)) => {
848 let (artifact, stopped) =
849 await_web_socket_capture(closer, completion, cancel.clone()).await?;
850 completed_at = if stopped {
851 artifact.last_activity_at.unwrap_or(completed_at)
852 } else {
853 artifact.closed_at.max(completed_at)
854 };
855 Some(artifact)
856 }
857 None => None,
858 };
859
860 build_entry_artifact(
861 dir,
862 started_date_time,
863 elapsed_millis(begin, completed_at),
864 request,
865 body_mime_type,
866 request_artifact,
867 response,
868 web_socket,
869 temp_cleanup,
870 )
871 .await
872}
873
874async fn await_web_socket_capture(
875 closer: WebSocketCaptureCloseHandle,
876 mut completion: WebSocketCaptureCompletion,
877 mut cancel: watch::Receiver<bool>,
878) -> Result<(WebSocketArtifact, bool), BoxError> {
879 if *cancel.borrow() {
880 closer.close();
881 } else {
882 tokio::select! {
883 biased;
884 result = &mut completion => {
885 return result
886 .context("WebSocket HAR capture completion dropped")?
887 .map(|artifact| (artifact, false));
888 },
889 changed = cancel.changed() => {
890 if changed.is_err() || *cancel.borrow() {
891 closer.close();
892 }
893 }
894 }
895 }
896 completion
897 .await
898 .context("WebSocket HAR capture completion dropped")?
899 .map(|artifact| (artifact, true))
900}
901
902async fn spool_body(
903 mut stream: BodyCaptureStream,
904 dir: PathBuf,
905 temp_cleanup: TempPathCleanup,
906 mut cancel: watch::Receiver<bool>,
907) -> Result<BodyArtifact, BoxError> {
908 let (path, file) = create_temp_file(dir, "body", temp_cleanup).await?;
909 let mut file = BufWriter::new(file);
910 let mut size = 0_i64;
911 let outcome = loop {
912 if *cancel.borrow() {
913 if let Some(BodyCaptureEvent::Frame(frame)) = stream.try_next_event()
914 && let Ok(data) = frame.into_data()
915 {
916 file.write_all(&data)
917 .await
918 .context("spool accepted HAR body frame during stop")?;
919 size = size.saturating_add(i64::try_from(data.len()).unwrap_or(i64::MAX));
920 }
921 break CaptureOutcome::Aborted;
922 }
923 tokio::select! {
924 biased;
925 event = stream.next_event() => match event {
926 Some(BodyCaptureEvent::Frame(frame)) => {
927 if let Ok(data) = frame.into_data() {
928 file.write_all(&data).await.context("spool HAR body frame")?;
929 size = size.saturating_add(i64::try_from(data.len()).unwrap_or(i64::MAX));
930 }
931 }
932 Some(BodyCaptureEvent::End(outcome)) => break outcome,
933 None => break CaptureOutcome::Aborted,
934 },
935 _ = cancel.changed() => {}
936 }
937 };
938 let finished_at = Instant::now();
939 file.flush().await.context("flush HAR body artifact")?;
940 drop(file);
941 Ok(BodyArtifact {
942 path,
943 size,
944 outcome,
945 finished_at,
946 })
947}
948
949async fn create_temp_file(
950 dir: PathBuf,
951 kind: &'static str,
952 temp_cleanup: TempPathCleanup,
953) -> Result<(TempPath, File), BoxError> {
954 let file_name = format!(".rama-har-{kind}-{}", Uuid::new_v4().as_simple());
957 let path = dir.join(&file_name);
958 let file = OpenOptions::new()
959 .write(true)
960 .create_new(true)
961 .created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
962 .jail(&dir)
963 .open(file_name)
964 .await
965 .context("create private HAR artifact")?;
966 Ok((TempPath::new(path, temp_cleanup), file))
967}
968
969fn create_temp_file_sync(
970 dir: &Path,
971 kind: &'static str,
972 temp_cleanup: TempPathCleanup,
973) -> Result<(TempPath, std::fs::File), BoxError> {
974 let file_name = format!(".rama-har-{kind}-{}", Uuid::new_v4().as_simple());
976 let path = dir.join(&file_name);
977 let file = OpenOptionsSync::new()
978 .write(true)
979 .create_new(true)
980 .created_file_permissions(CreatedFilePermissions::OwnerReadWrite)
981 .jail(dir)
982 .open(file_name)
983 .context("create private HAR artifact")?;
984 Ok((TempPath::new(path, temp_cleanup), file))
985}
986
987fn elapsed_millis(begin: Instant, completed_at: Instant) -> i64 {
988 completed_at
989 .saturating_duration_since(begin)
990 .as_millis()
991 .min(i64::MAX as u128) as i64
992}
993
994#[expect(clippy::too_many_arguments)]
995async fn build_entry_artifact(
996 dir: PathBuf,
997 started_date_time: Timestamp,
998 elapsed_time: i64,
999 mut request: spec::Request,
1000 request_mime_type: Option<crate::mime::Mime>,
1001 request_body: BodyArtifact,
1002 response: Option<(spec::Response, BodyArtifact)>,
1003 web_socket: Option<WebSocketArtifact>,
1004 temp_cleanup: TempPathCleanup,
1005) -> Result<TempPath, BoxError> {
1006 tokio::task::spawn_blocking(move || {
1007 request.body_size = request_body.size;
1011 let form_urlencoded = request_mime_type
1012 .as_ref()
1013 .is_some_and(|mime| mime.subtype() == crate::mime::WWW_FORM_URLENCODED);
1014 request.post_data = (request_body.size > 0).then(|| spec::PostData {
1015 mime_type: request_mime_type,
1016 params: form_urlencoded.then(Vec::new),
1017 text: None,
1018 comment: None,
1019 });
1020
1021 let (response, response_body) = match response {
1022 Some((mut response, body)) => {
1023 response.body_size = body.size;
1024 response.content.size = body.size;
1025 response.content.text = None;
1026 response.content.encoding = None;
1027 (response, Some(body))
1028 }
1029 None => (
1030 spec::Response {
1031 status: 0,
1032 status_text: Some("".into()),
1033 http_version: request.http_version.clone(),
1034 cookies: Vec::new(),
1035 headers: Vec::new(),
1036 content: spec::Content {
1037 size: 0,
1038 compression: None,
1039 mime_type: Some(crate::mime::APPLICATION_OCTET_STREAM),
1040 text: None,
1041 encoding: None,
1042 comment: None,
1043 },
1044 redirect_url: Some("".into()),
1045 headers_size: -1,
1046 body_size: -1,
1047 comment: None,
1048 },
1049 None,
1050 ),
1051 };
1052
1053 let entry = spec::Entry {
1054 page_ref: None,
1055 started_date_time,
1056 time: elapsed_time,
1057 request,
1058 response,
1059 cache: spec::Cache::default(),
1060 timings: spec::Timings::default(),
1061 server_ip_address: None,
1062 connection: None,
1063 comment: None,
1064 resource_type: web_socket.as_ref().map(|_| "websocket".into()),
1065 web_socket_messages: web_socket.as_ref().map(|_| Vec::new()),
1066 };
1067
1068 let (path, mut file) = create_temp_file_sync(&dir, "entry", temp_cleanup)
1069 .context("create private HAR entry artifact")?;
1070 write_streaming_entry(
1071 &mut file,
1072 &entry,
1073 &request_body,
1074 response_body.as_ref(),
1075 web_socket.as_ref(),
1076 )?;
1077 file.flush().context("flush HAR entry artifact")?;
1078 tracing::trace!(
1079 request_outcome = ?request_body.outcome,
1080 response_outcome = ?response_body.as_ref().map(|body| body.outcome),
1081 "completed streaming HAR entry artifact"
1082 );
1083 Ok(path)
1084 })
1085 .await
1086 .context("join HAR entry serialization task")?
1087}
1088
1089fn write_streaming_entry(
1090 writer: &mut impl Write,
1091 entry: &spec::Entry,
1092 request_body: &BodyArtifact,
1093 response_body: Option<&BodyArtifact>,
1094 web_socket: Option<&WebSocketArtifact>,
1095) -> Result<(), BoxError> {
1096 let value = serde_json::to_value(entry).context("serialize HAR entry metadata")?;
1097 write_json_object(writer, &value, |writer, key, value| match key {
1098 "request" => {
1099 write_request(writer, value, request_body)?;
1100 Ok(true)
1101 }
1102 "response" => {
1103 if let Some(response_body) = response_body {
1104 write_response(writer, value, response_body)?;
1105 Ok(true)
1106 } else {
1107 Ok(false)
1108 }
1109 }
1110 "_webSocketMessages" => {
1111 if let Some(web_socket) = web_socket {
1112 write_web_socket_messages(writer, web_socket)?;
1113 Ok(true)
1114 } else {
1115 Ok(false)
1116 }
1117 }
1118 _ => Ok(false),
1119 })
1120}
1121
1122fn write_web_socket_messages(
1123 writer: &mut impl Write,
1124 messages: &WebSocketArtifact,
1125) -> Result<(), BoxError> {
1126 writer.write_all(b"[")?;
1127 let mut file = std::io::BufReader::new(
1128 safe_open_sync(&messages.path).context("open WebSocket HAR artifact")?,
1129 );
1130 std::io::copy(&mut file, writer).context("copy WebSocket HAR messages")?;
1131 writer.write_all(b"]")?;
1132 Ok(())
1133}
1134
1135fn write_request(
1136 writer: &mut impl Write,
1137 request: &Value,
1138 body: &BodyArtifact,
1139) -> Result<(), BoxError> {
1140 write_json_object(writer, request, |writer, key, value| {
1141 if key == "postData" && body.size > 0 {
1142 write_json_object(writer, value, |writer, key, _| match key {
1143 "params" if value["params"].is_array() => {
1144 write_form_params(writer, body)?;
1145 Ok(true)
1146 }
1147 "text" => {
1148 write_body_text(writer, body)?;
1149 Ok(true)
1150 }
1151 _ => Ok(false),
1152 })?;
1153 Ok(true)
1154 } else {
1155 Ok(false)
1156 }
1157 })
1158}
1159
1160fn write_form_params(writer: &mut impl Write, body: &BodyArtifact) -> Result<(), BoxError> {
1161 let file = safe_open_sync(&body.path).context("open form-encoded HAR body artifact")?;
1162 let mut reader = std::io::BufReader::new(file);
1163 let mut segment = Vec::new();
1164 let mut first = true;
1165 writer.write_all(b"[")?;
1166 loop {
1167 segment.clear();
1168 let read = reader
1169 .read_until(b'&', &mut segment)
1170 .context("read form-encoded HAR body")?;
1171 if read == 0 {
1172 break;
1173 }
1174 if segment.last() == Some(&b'&') {
1175 segment.pop();
1176 }
1177 let pairs: Vec<(String, String)> = serde_html_form::from_bytes(&segment)
1178 .context("decode form-encoded HAR body parameter")?;
1179 for (name, value) in pairs {
1180 if !first {
1181 writer.write_all(b",")?;
1182 }
1183 serde_json::to_writer(
1184 &mut *writer,
1185 &spec::PostParam {
1186 name: name.into(),
1187 value: Some(value.into()),
1188 file_name: None,
1189 content_type: None,
1190 comment: None,
1191 },
1192 )?;
1193 first = false;
1194 }
1195 }
1196 writer.write_all(b"]")?;
1197 Ok(())
1198}
1199
1200fn write_response(
1201 writer: &mut impl Write,
1202 response: &Value,
1203 body: &BodyArtifact,
1204) -> Result<(), BoxError> {
1205 write_json_object(writer, response, |writer, key, value| {
1206 if key == "content" {
1207 let is_text = body_is_utf8(&body.path)?;
1208 write_json_object(writer, value, |writer, key, _value| match key {
1209 "text" if body.size > 0 => {
1210 write_body_text_with_encoding(writer, body, is_text)?;
1211 Ok(true)
1212 }
1213 "encoding" if body.size > 0 && !is_text => {
1214 serde_json::to_writer(writer, "base64")?;
1215 Ok(true)
1216 }
1217 _ => Ok(false),
1218 })?;
1219 Ok(true)
1220 } else {
1221 Ok(false)
1222 }
1223 })
1224}
1225
1226fn write_json_object<W, F>(
1227 writer: &mut W,
1228 value: &Value,
1229 mut override_field: F,
1230) -> Result<(), BoxError>
1231where
1232 W: Write,
1233 F: FnMut(&mut W, &str, &Value) -> Result<bool, BoxError>,
1234{
1235 let object = value
1236 .as_object()
1237 .ok_or_else(|| std::io::Error::other("expected serialized HAR object"))?;
1238 writer.write_all(b"{")?;
1239 for (index, (key, value)) in object.iter().enumerate() {
1240 if index > 0 {
1241 writer.write_all(b",")?;
1242 }
1243 serde_json::to_writer(&mut *writer, key)?;
1244 writer.write_all(b":")?;
1245 if !override_field(writer, key, value)? {
1246 serde_json::to_writer(&mut *writer, value)?;
1247 }
1248 }
1249 writer.write_all(b"}")?;
1250 Ok(())
1251}
1252
1253fn write_body_text(writer: &mut impl Write, body: &BodyArtifact) -> Result<(), BoxError> {
1254 let is_text = body_is_utf8(&body.path)?;
1255 write_body_text_with_encoding(writer, body, is_text)
1256}
1257
1258fn write_body_text_with_encoding(
1259 writer: &mut impl Write,
1260 body: &BodyArtifact,
1261 is_text: bool,
1262) -> Result<(), BoxError> {
1263 writer.write_all(b"\"")?;
1264 let file = safe_open_sync(&body.path).context("open HAR body artifact")?;
1265 if is_text {
1266 write_escaped_utf8(writer, file)?;
1267 } else {
1268 let mut encoder = base64::write::EncoderWriter::new(&mut *writer, &BASE64);
1269 std::io::copy(&mut std::io::BufReader::new(file), &mut encoder)
1270 .context("base64 encode HAR body")?;
1271 encoder.finish().context("finish base64 HAR body")?;
1272 }
1273 writer.write_all(b"\"")?;
1274 Ok(())
1275}
1276
1277fn body_is_utf8(path: &Path) -> Result<bool, BoxError> {
1278 let mut file = std::io::BufReader::new(
1279 safe_open_sync(path).context("open HAR body for UTF-8 validation")?,
1280 );
1281 let mut buffer = [0_u8; 8192];
1282 let mut trailing = Vec::with_capacity(4);
1283 loop {
1284 let read = file
1285 .read(&mut buffer)
1286 .context("read HAR body for UTF-8 validation")?;
1287 if read == 0 {
1288 return Ok(trailing.is_empty());
1289 }
1290 let mut bytes = Vec::with_capacity(trailing.len() + read);
1291 bytes.extend_from_slice(&trailing);
1292 bytes.extend_from_slice(&buffer[..read]);
1293 trailing.clear();
1294 if let Err(err) = std::str::from_utf8(&bytes) {
1295 if err.error_len().is_some() {
1296 return Ok(false);
1297 }
1298 trailing.extend_from_slice(&bytes[err.valid_up_to()..]);
1299 if trailing.len() > 3 {
1300 return Ok(false);
1301 }
1302 }
1303 }
1304}
1305
1306fn write_escaped_utf8(writer: &mut impl Write, mut file: impl Read) -> Result<(), BoxError> {
1307 let mut buffer = [0_u8; 8192];
1308 let mut trailing = Vec::with_capacity(4);
1309 loop {
1310 let read = file.read(&mut buffer).context("read UTF-8 HAR body")?;
1311 if read == 0 {
1312 if !trailing.is_empty() {
1313 let fragment = std::str::from_utf8(&trailing)
1314 .context("validate trailing UTF-8 HAR body bytes")?;
1315 write_escaped_fragment(writer, fragment)?;
1316 }
1317 return Ok(());
1318 }
1319 let mut bytes = Vec::with_capacity(trailing.len() + read);
1320 bytes.extend_from_slice(&trailing);
1321 bytes.extend_from_slice(&buffer[..read]);
1322 trailing.clear();
1323 match std::str::from_utf8(&bytes) {
1324 Ok(fragment) => write_escaped_fragment(writer, fragment)?,
1325 Err(err) if err.error_len().is_none() => {
1326 let fragment = std::str::from_utf8(&bytes[..err.valid_up_to()])
1327 .context("validate complete UTF-8 HAR body prefix")?;
1328 write_escaped_fragment(writer, fragment)?;
1329 trailing.extend_from_slice(&bytes[err.valid_up_to()..]);
1330 }
1331 Err(err) => return Err(err).context("validate UTF-8 HAR body"),
1332 }
1333 }
1334}
1335
1336fn write_escaped_fragment(writer: &mut impl Write, fragment: &str) -> Result<(), BoxError> {
1337 let escaped = serde_json::to_vec(fragment).context("escape UTF-8 HAR body fragment")?;
1338 writer.write_all(&escaped[1..escaped.len() - 1])?;
1339 Ok(())
1340}
1341
1342async fn create_har_parent_dir(parent: &Path) -> std::io::Result<()> {
1343 #[cfg(unix)]
1344 {
1345 use std::os::unix::fs::DirBuilderExt as _;
1346 let mut builder = std::fs::DirBuilder::new();
1347 builder.recursive(true).mode(0o700);
1348 let parent = parent.to_owned();
1349 tokio::task::spawn_blocking(move || builder.create(&parent))
1350 .await
1351 .map_err(std::io::Error::other)??;
1352 Ok(())
1353 }
1354 #[cfg(not(unix))]
1355 {
1356 tokio::fs::create_dir_all(parent).await
1357 }
1358}
1359
1360async fn finish_storage(storage: Storage) {
1361 let Storage {
1362 mut file,
1363 path,
1364 valid,
1365 ..
1366 } = storage;
1367 if valid {
1368 let result = async {
1369 file.write_all(b"]}}").await?;
1370 file.flush().await
1371 }
1372 .await;
1373 match result {
1374 Ok(()) => return,
1375 Err(err) => tracing::debug!("failed to finish HAR file: {err}"),
1376 }
1377 }
1378
1379 drop(file);
1380 if let Err(err) = tokio::fs::remove_file(path).await {
1381 tracing::debug!("failed to remove invalid HAR file: {err}");
1382 }
1383}
1384
1385impl Default for FileRecorder {
1386 fn default() -> Self {
1387 Self::new(
1388 std::env::temp_dir().join("rama").join("har_recordings"),
1389 format!(
1390 "rama_{}_recording",
1391 rama_utils::info::VERSION.replace('.', "_")
1392 ),
1393 )
1394 }
1395}
1396
1397impl FileRecorder {
1398 #[must_use]
1403 pub fn new(dir: PathBuf, prefix: String) -> Self {
1404 Self::new_with_log_meta_info(dir, prefix, LogMetaInfo::default())
1405 }
1406
1407 pub fn try_new_at(path: impl AsRef<Path>) -> Result<Self, BoxError> {
1412 Self::try_new_at_with_log_meta_info(path, LogMetaInfo::default())
1413 }
1414
1415 pub fn try_new_at_with_log_meta_info(
1417 path: impl AsRef<Path>,
1418 log_meta_info: LogMetaInfo,
1419 ) -> Result<Self, BoxError> {
1420 let path = path.as_ref();
1421 let file_name = path
1422 .file_name()
1423 .filter(|file_name| !file_name.is_empty())
1424 .ok_or_else(|| std::io::Error::other("HAR file path has no file name"))?
1425 .to_owned();
1426 let dir = path
1427 .parent()
1428 .filter(|parent| !parent.as_os_str().is_empty())
1429 .unwrap_or_else(|| Path::new("."))
1430 .to_owned();
1431 Ok(Self::new_with_output(
1432 dir,
1433 FileRecorderOutput::Exact { file_name },
1434 log_meta_info,
1435 ))
1436 }
1437
1438 #[must_use]
1443 pub fn new_with_log_meta_info(
1444 dir: PathBuf,
1445 prefix: String,
1446 log_meta_info: LogMetaInfo,
1447 ) -> Self {
1448 Self::new_with_output(dir, FileRecorderOutput::Generated { prefix }, log_meta_info)
1449 }
1450
1451 fn new_with_output(
1452 dir: PathBuf,
1453 output: FileRecorderOutput,
1454 log_meta_info: LogMetaInfo,
1455 ) -> Self {
1456 let (tx, rx) = mpsc::channel(match std::thread::available_parallelism() {
1457 Ok(parallelism) => parallelism.get(),
1458 Err(_) => 1,
1459 });
1460 Self {
1461 tx,
1462 task: Arc::new(FileRecorderTaskStarter {
1463 once: Once::new(),
1464 task: Mutex::new(Some(FileRecorderTask::new(rx, dir, output, log_meta_info))),
1465 }),
1466 }
1467 }
1468
1469 fn start_task(&self) {
1470 self.task.once.call_once(|| {
1471 let task = self.task.task.lock().take();
1472 if let Some(task) = task {
1473 tokio::spawn(task.run());
1474 }
1475 });
1476 }
1477}
1478
1479impl RecorderSession for FileRecorderSession {
1480 fn web_socket_capture(&self) -> Option<WebSocketCapture> {
1481 self.web_socket_capture.clone()
1482 }
1483
1484 async fn record_response(self, response: HttpResponseCapture) -> Option<Extensions> {
1485 if let Err(err) = self
1486 .tx
1487 .send(CaptureWorkerMessage::Response {
1488 response: Box::new(response),
1489 headers_at: Instant::now(),
1490 })
1491 .await
1492 {
1493 if let Some(capture) = &self.web_socket_capture {
1494 capture.close();
1495 }
1496 tracing::debug!("failed to attach response to HAR capture worker: {err}");
1497 return None;
1498 }
1499 let extensions = Extensions::new();
1500 extensions.insert(self.path);
1501 Some(extensions)
1502 }
1503
1504 async fn record_request_only(self) -> Option<Extensions> {
1505 if let Some(capture) = &self.web_socket_capture {
1506 capture.close();
1507 }
1508 if let Err(err) = self.tx.send(CaptureWorkerMessage::RequestOnly).await {
1509 tracing::debug!("failed to finish request-only HAR capture: {err}");
1510 return None;
1511 }
1512 let extensions = Extensions::new();
1513 extensions.insert(self.path);
1514 Some(extensions)
1515 }
1516}
1517
1518impl StreamingRecorder for FileRecorder {
1519 type Session = FileRecorderSession;
1520
1521 async fn start_http_recording(&self, request: HttpRequestCapture) -> Option<Self::Session> {
1522 self.start_task();
1523 let (reply, response) = oneshot::channel();
1524 if let Err(err) = self
1525 .tx
1526 .send(FileRecorderMessage::StartHttp {
1527 request: Box::new(request),
1528 reply,
1529 })
1530 .await
1531 {
1532 tracing::debug!("failed to start streaming HAR recording: {err}");
1533 return None;
1534 }
1535 response
1536 .await
1537 .inspect_err(|err| tracing::debug!("HAR start reply failed: {err}"))
1538 .ok()
1539 .flatten()
1540 }
1541}
1542
1543impl Recorder for FileRecorder {
1544 async fn record(&self, log: spec::Log) -> Option<Extensions> {
1545 self.start_task();
1546 let (reply, response) = oneshot::channel();
1547 if let Err(err) = self
1548 .tx
1549 .send(FileRecorderMessage::Record {
1550 log: Box::new(log),
1551 reply,
1552 })
1553 .await
1554 {
1555 tracing::debug!("failed to send materialized HAR log to recorder: {err}");
1556 return None;
1557 }
1558 response
1559 .await
1560 .inspect_err(|err| tracing::debug!("HAR record reply failed: {err}"))
1561 .ok()
1562 }
1563
1564 async fn stop_record(&self) {
1565 self.start_task();
1566 let (done, response) = oneshot::channel();
1567 if let Err(err) = self.tx.send(FileRecorderMessage::Stop { done }).await {
1568 tracing::debug!("failed to send stop to HAR recorder: {err}");
1569 return;
1570 }
1571 if let Err(err) = response.await {
1572 tracing::debug!("failed to await HAR recorder stop: {err}");
1573 }
1574 }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580
1581 #[test]
1582 fn file_recorder_can_be_constructed_without_a_runtime() {
1583 let recorder = FileRecorder::default();
1584 assert!(recorder.task.task.lock().is_some());
1585 }
1586
1587 #[tokio::test]
1588 async fn stop_before_any_recording_is_a_no_op() {
1589 let dir = tempfile::tempdir().unwrap();
1590 let recorder = FileRecorder::new(dir.path().to_owned(), "unused".to_owned());
1591
1592 tokio::time::timeout(std::time::Duration::from_secs(2), recorder.stop_record())
1593 .await
1594 .unwrap();
1595
1596 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
1597 }
1598
1599 #[tokio::test]
1600 async fn exact_path_recorder_writes_the_requested_file() {
1601 let dir = tempfile::tempdir().unwrap();
1602 let path = dir.path().join("nested").join("capture.har");
1603 let recorder = FileRecorder::try_new_at(path.clone()).unwrap();
1604
1605 let extensions = recorder.record(spec::Log::default()).await.unwrap();
1606 assert_eq!(extensions.get_ref::<HarFilePath>().unwrap().as_ref(), path);
1607 recorder.stop_record().await;
1608
1609 let bytes = tokio::fs::read(&path).await.unwrap();
1610 serde_json::from_slice::<spec::LogFile>(&bytes).unwrap();
1611 assert_eq!(
1612 std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1613 1
1614 );
1615 }
1616
1617 #[tokio::test]
1618 async fn dropping_temp_path_defers_file_io_to_cleanup_worker() {
1619 let dir = tempfile::tempdir().unwrap();
1620 let path = dir.path().join("artifact");
1621 tokio::fs::write(&path, b"temporary").await.unwrap();
1622 let (temp_cleanup, temp_cleanup_rx) = TempPathCleanup::new();
1623
1624 drop(TempPath::new(path.clone(), temp_cleanup.clone()));
1625 assert!(path.exists(), "TempPath::drop must not perform file I/O");
1626
1627 let temp_cleanup_task = tokio::spawn(remove_temp_paths(temp_cleanup_rx));
1628 temp_cleanup.flush().await;
1629 assert!(!path.exists());
1630 drop(temp_cleanup);
1631 temp_cleanup_task.await.unwrap();
1632 }
1633
1634 #[tokio::test]
1635 async fn stop_record_waits_until_the_har_file_is_complete() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let recorder = FileRecorder::new(dir.path().to_owned(), "recording".to_owned());
1638
1639 let extensions = recorder.record(spec::Log::default()).await.unwrap();
1640 let path = extensions.get_ref::<HarFilePath>().unwrap().to_owned();
1641
1642 recorder.stop_record().await;
1643
1644 let bytes = tokio::fs::read(path.as_ref()).await.unwrap();
1645 serde_json::from_slice::<spec::LogFile>(&bytes).unwrap();
1646 }
1647
1648 #[tokio::test]
1649 async fn rollback_keeps_the_last_complete_har_entry_valid() {
1650 let dir = tempfile::tempdir().unwrap();
1651 let path = dir.path().join("rollback.har");
1652 let artifact = dir.path().join("entry.json");
1653 tokio::fs::write(&artifact, br#"{"marker":true}"#)
1654 .await
1655 .unwrap();
1656 let mut storage = Storage::try_new(path.clone(), &spec::Log::default())
1657 .await
1658 .unwrap();
1659 storage.append_artifact(&artifact).await.unwrap();
1660 let checkpoint = storage.valid_position;
1661
1662 storage.file.write_all(b",{\"truncated\":").await.unwrap();
1663 storage.valid = false;
1664 storage.rollback(checkpoint).await.unwrap();
1665 storage.valid = true;
1666 finish_storage(storage).await;
1667
1668 let value: Value = serde_json::from_slice(&tokio::fs::read(path).await.unwrap()).unwrap();
1669 assert_eq!(
1670 value["log"]["entries"],
1671 serde_json::json!([{"marker": true}])
1672 );
1673 }
1674
1675 #[tokio::test]
1676 async fn completed_workers_are_written_in_request_start_order() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let (temp_cleanup, temp_cleanup_rx) = TempPathCleanup::new();
1679 let temp_cleanup_task = tokio::spawn(remove_temp_paths(temp_cleanup_rx));
1680 let path = dir.path().join("ordered.har");
1681 let mut storage = Some(
1682 Storage::try_new(path.clone(), &spec::Log::default())
1683 .await
1684 .unwrap(),
1685 );
1686 let mut completed = BTreeMap::new();
1687 let mut next_sequence_to_write = 0;
1688
1689 let (first_path, mut first) =
1690 create_temp_file_sync(dir.path(), "first", temp_cleanup.clone()).unwrap();
1691 first.write_all(br#"{"sequence":0}"#).unwrap();
1692 first.flush().unwrap();
1693 drop(first);
1694 let (second_path, mut second) =
1695 create_temp_file_sync(dir.path(), "second", temp_cleanup.clone()).unwrap();
1696 second.write_all(br#"{"sequence":1}"#).unwrap();
1697 second.flush().unwrap();
1698 drop(second);
1699
1700 assert!(
1701 !handle_worker(
1702 Some(Ok((1, Ok(second_path)))),
1703 &mut storage,
1704 &mut completed,
1705 &mut next_sequence_to_write,
1706 )
1707 .await
1708 );
1709 assert!(
1710 !storage.as_ref().unwrap().has_entries,
1711 "a later capture must wait for the earlier capture"
1712 );
1713 assert!(
1714 !handle_worker(
1715 Some(Ok((0, Ok(first_path)))),
1716 &mut storage,
1717 &mut completed,
1718 &mut next_sequence_to_write,
1719 )
1720 .await
1721 );
1722 assert!(completed.is_empty());
1723 assert_eq!(next_sequence_to_write, 2);
1724
1725 finish_storage(storage.take().unwrap()).await;
1726 temp_cleanup.flush().await;
1727 let value: Value = serde_json::from_slice(&tokio::fs::read(path).await.unwrap()).unwrap();
1728 assert_eq!(
1729 value["log"]["entries"],
1730 serde_json::json!([{"sequence": 0}, {"sequence": 1}])
1731 );
1732 drop(temp_cleanup);
1733 temp_cleanup_task.await.unwrap();
1734 }
1735
1736 #[tokio::test]
1737 async fn failed_storage_generation_discards_its_remaining_workers() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let (temp_cleanup, temp_cleanup_rx) = TempPathCleanup::new();
1740 let temp_cleanup_task = tokio::spawn(remove_temp_paths(temp_cleanup_rx));
1741 let old_path = dir.path().join("old.har");
1742 let fresh_path = dir.path().join("fresh.har");
1743 let mut storage = Some(
1744 Storage::try_new(old_path.clone(), &spec::Log::default())
1745 .await
1746 .unwrap(),
1747 );
1748 let missing_artifact =
1749 TempPath::new(dir.path().join("missing-entry.json"), temp_cleanup.clone());
1750 let mut completed = BTreeMap::new();
1751 let mut next_sequence_to_write = 0;
1752
1753 assert!(
1754 handle_worker(
1755 Some(Ok((0, Ok(missing_artifact)))),
1756 &mut storage,
1757 &mut completed,
1758 &mut next_sequence_to_write,
1759 )
1760 .await,
1761 "an append failure invalidates the active generation"
1762 );
1763
1764 let (artifact_path, mut artifact) =
1765 create_temp_file_sync(dir.path(), "late-entry", temp_cleanup.clone()).unwrap();
1766 artifact.write_all(br#"{"late":true}"#).unwrap();
1767 artifact.flush().unwrap();
1768 drop(artifact);
1769 let artifact_path_copy = artifact_path.path.clone();
1770 let mut workers = JoinSet::new();
1771 workers.spawn(async move { (1, Ok::<_, BoxError>(artifact_path)) });
1772 let (cancel, _) = watch::channel(false);
1773
1774 reset_failed_generation(
1775 &cancel,
1776 &mut workers,
1777 &mut storage,
1778 &mut completed,
1779 2,
1780 &mut next_sequence_to_write,
1781 )
1782 .await;
1783 temp_cleanup.flush().await;
1784
1785 assert!(storage.is_none());
1786 assert!(workers.is_empty());
1787 assert!(completed.is_empty());
1788 assert_eq!(next_sequence_to_write, 2);
1789 assert!(!artifact_path_copy.exists());
1790
1791 finish_storage(
1792 Storage::try_new(fresh_path.clone(), &spec::Log::default())
1793 .await
1794 .unwrap(),
1795 )
1796 .await;
1797 for path in [old_path, fresh_path] {
1798 let log: spec::LogFile =
1799 serde_json::from_slice(&tokio::fs::read(path).await.unwrap()).unwrap();
1800 assert!(
1801 log.log.entries.is_empty(),
1802 "a worker from the failed generation must not reach another HAR file"
1803 );
1804 }
1805 drop(temp_cleanup);
1806 temp_cleanup_task.await.unwrap();
1807 }
1808}