Skip to main content

rama_http/layer/har/recorder/
fs.rs

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/// Recorder that creates one file per recording session.
113///
114/// Live HTTP bodies are captured into private per-exchange temporary files.
115/// Completed entries are then copied into the destination HAR by one writer,
116/// so concurrent streams cannot interleave and body memory remains bounded.
117#[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))]
138/// Path to the HAR file that the [`FileRecorder`] is recording into.
139///
140/// Inserted into response extensions. The file remains an in-progress JSON
141/// document until [`Recorder::stop_record`] completes.
142pub 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        // Archives can contain credentials, cookies, and bodies. Apply 0600 at
221        // creation on Unix so their bytes are never briefly world-readable.
222        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            // A join failure does not contain the sequence returned by the
600            // worker, so the current generation can no longer be ordered.
601            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    // Keep the path guard first so destructuring callers drop the file before
955    // the guard queues its removal, including while unwinding an error.
956    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    // Keep the same drop order for blocking serializer tasks.
975    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        // Keep the body-bearing fields present as null placeholders. The
1008        // streaming JSON writer below replaces their serialized values with
1009        // spooled body data without materializing those bodies in memory.
1010        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    /// Create a recorder for the given directory and filename prefix.
1399    ///
1400    /// Construction does not require an active Tokio runtime. The recorder's
1401    /// worker starts lazily when its first asynchronous operation is polled.
1402    #[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    /// Create a recorder that writes to one exact file path.
1408    ///
1409    /// The parent directory is created when recording starts. Starting a new
1410    /// recording after [`Recorder::stop_record`] replaces the same file.
1411    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    /// Create an exact-path recorder with explicit HAR log metadata.
1416    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    /// Create a recorder with explicit HAR log metadata.
1439    ///
1440    /// Construction does not require an active Tokio runtime. The recorder's
1441    /// worker starts lazily when its first asynchronous operation is polled.
1442    #[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}