hurl/runner/
entry.rs

1/*
2 * Hurl (https://hurl.dev)
3 * Copyright (C) 2025 Orange
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *          http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17 */
18use hurl_core::ast::{Entry, PredicateFuncValue, Response, SourceInfo};
19
20use crate::http;
21use crate::http::{ClientOptions, CurlCmd};
22use crate::runner::cache::BodyCache;
23use crate::runner::error::RunnerError;
24use crate::runner::result::{AssertResult, EntryResult};
25use crate::runner::runner_options::RunnerOptions;
26use crate::runner::{request, response, CaptureResult, RunnerErrorKind, VariableSet};
27use crate::util::logger::{Logger, Verbosity};
28use crate::util::term::WriteMode;
29
30/// Runs an `entry` with `http_client` and returns one [`EntryResult`].
31///
32/// The `calls` field of the [`EntryResult`] contains a list of HTTP requests and responses that have
33/// been executed. If `http_client` has been configured to follow redirection, the `calls` list contains
34/// every step of the redirection for the first to the last.
35/// `variables` are used to render values at runtime, and can be updated by captures.
36pub fn run(
37    entry: &Entry,
38    entry_index: usize,
39    http_client: &mut http::Client,
40    variables: &mut VariableSet,
41    runner_options: &RunnerOptions,
42    logger: &mut Logger,
43) -> EntryResult {
44    let compressed = runner_options.compressed;
45    let source_info = entry.source_info();
46    let context_dir = &runner_options.context_dir;
47
48    // We don't allow creating secrets if the logger is immediate and verbose because, in this case,
49    // network logs have already been written and may have leaked secrets before captures evaluation.
50    // Note: in `--test` mode, the logger is buffered so there is no restriction on logger level.
51    if let Some(response_spec) = &entry.response {
52        let immediate_logs =
53            matches!(logger.stderr.mode(), WriteMode::Immediate) && logger.verbosity.is_some();
54        if immediate_logs {
55            let redacted = response_spec.captures().iter().find(|c| c.redacted);
56            if let Some(redacted) = redacted {
57                let source_info = redacted.name.source_info;
58                let error =
59                    RunnerError::new(source_info, RunnerErrorKind::PossibleLoggedSecret, false);
60                return EntryResult {
61                    entry_index,
62                    source_info,
63                    errors: vec![error],
64                    compressed,
65                    ..Default::default()
66                };
67            }
68        }
69    }
70
71    // Evaluates our source requests given our set of variables
72    let http_request = match request::eval_request(&entry.request, variables, context_dir) {
73        Ok(r) => r,
74        Err(error) => {
75            return EntryResult {
76                entry_index,
77                source_info,
78                errors: vec![error],
79                compressed,
80                ..Default::default()
81            };
82        }
83    };
84
85    let client_options = ClientOptions::from(runner_options, logger.verbosity);
86
87    // Experimental features with cookie storage
88    use std::str::FromStr;
89    if let Some(s) = request::cookie_storage_set(&entry.request) {
90        if let Ok(cookie) = http::Cookie::from_str(s.as_str()) {
91            http_client.add_cookie(&cookie, logger);
92        } else {
93            logger.warning(&format!("Cookie string can not be parsed: '{s}'"));
94        }
95    }
96    if request::cookie_storage_clear(&entry.request) {
97        http_client.clear_cookie_storage(logger);
98    }
99
100    let curl_cmd = http_client.curl_command_line(
101        &http_request,
102        context_dir,
103        runner_options.output.as_ref(),
104        &client_options,
105        logger,
106    );
107
108    log_request(http_client, &curl_cmd, &http_request, logger);
109
110    // Run the HTTP requests (optionally follow redirection)
111    let calls = match http_client.execute_with_redirect(&http_request, &client_options, logger) {
112        Ok(calls) => calls,
113        Err(http_error) => {
114            let start = entry.request.url.source_info.start;
115            let end = entry.request.url.source_info.end;
116            let error_source_info = SourceInfo::new(start, end);
117            let error =
118                RunnerError::new(error_source_info, RunnerErrorKind::Http(http_error), false);
119            return EntryResult {
120                entry_index,
121                source_info,
122                errors: vec![error],
123                compressed,
124                curl_cmd,
125                ..Default::default()
126            };
127        }
128    };
129
130    // Now, we can compute capture and asserts on the last HTTP request/response chains.
131    let responses = calls.iter().map(|c| &c.response).collect::<Vec<_>>();
132    let http_response = responses.last().unwrap();
133
134    // `transfer_duration` represent the network time of calls, not including assert processing.
135    let transfer_duration = calls.iter().map(|call| call.timings.total).sum();
136
137    // We proceed asserts and captures in this order:
138    // 1. first, check implicit assert on status and version. If KO, test is failed
139    // 2. then, we compute captures, we might need them in asserts
140    // 3. finally, run the remaining asserts
141    let mut cache = BodyCache::new();
142    let mut asserts = vec![];
143
144    if !runner_options.ignore_asserts {
145        if let Some(response_spec) = &entry.response {
146            let mut status_asserts =
147                response::eval_version_status_asserts(response_spec, http_response);
148            let errors = asserts_to_errors(&status_asserts);
149            asserts.append(&mut status_asserts);
150            if !errors.is_empty() {
151                logger.debug("");
152                return EntryResult {
153                    entry_index,
154                    source_info,
155                    calls,
156                    captures: vec![],
157                    asserts,
158                    errors,
159                    transfer_duration,
160                    compressed,
161                    curl_cmd,
162                };
163            }
164        }
165    };
166
167    let captures = match &entry.response {
168        None => vec![],
169        Some(response_spec) => {
170            match response::eval_captures(response_spec, &responses, &mut cache, variables) {
171                Ok(captures) => captures,
172                Err(e) => {
173                    return EntryResult {
174                        entry_index,
175                        source_info,
176                        calls,
177                        captures: vec![],
178                        asserts,
179                        errors: vec![e],
180                        transfer_duration,
181                        compressed,
182                        curl_cmd,
183                    };
184                }
185            }
186        }
187    };
188
189    // After captures evaluation, we update the logger with secrets from the variable set. The variable
190    // set can have been updated with new secrets to redact.
191    logger.set_secrets(variables.secrets());
192
193    log_captures(&captures, logger);
194    logger.debug("");
195
196    // Compute asserts
197    if !runner_options.ignore_asserts {
198        if let Some(response_spec) = &entry.response {
199            warn_deprecated(response_spec, logger);
200            let mut other_asserts = response::eval_asserts(
201                response_spec,
202                variables,
203                &responses,
204                &mut cache,
205                context_dir,
206            );
207            asserts.append(&mut other_asserts);
208        }
209    };
210
211    let errors = asserts_to_errors(&asserts);
212
213    EntryResult {
214        entry_index,
215        source_info,
216        calls,
217        captures,
218        asserts,
219        errors,
220        transfer_duration,
221        compressed,
222        curl_cmd,
223    }
224}
225
226/// Converts a list of [`AssertResult`] to a list of [`RunnerError`].
227fn asserts_to_errors(asserts: &[AssertResult]) -> Vec<RunnerError> {
228    asserts
229        .iter()
230        .filter_map(|assert| assert.to_runner_error())
231        .map(
232            |RunnerError {
233                 source_info,
234                 kind: inner,
235                 ..
236             }| RunnerError::new(source_info, inner, true),
237        )
238        .collect()
239}
240
241impl ClientOptions {
242    fn from(runner_options: &RunnerOptions, verbosity: Option<Verbosity>) -> Self {
243        ClientOptions {
244            allow_reuse: runner_options.allow_reuse,
245            aws_sigv4: runner_options.aws_sigv4.clone(),
246            cacert_file: runner_options.cacert_file.clone(),
247            client_cert_file: runner_options.client_cert_file.clone(),
248            client_key_file: runner_options.client_key_file.clone(),
249            compressed: runner_options.compressed,
250            connect_timeout: runner_options.connect_timeout,
251            connects_to: runner_options.connects_to.clone(),
252            cookie_input_file: runner_options.cookie_input_file.clone(),
253            follow_location: runner_options.follow_location,
254            follow_location_trusted: runner_options.follow_location_trusted,
255            headers: runner_options.headers.clone(),
256            http_version: runner_options.http_version,
257            ip_resolve: runner_options.ip_resolve,
258            max_filesize: runner_options.max_filesize,
259            max_recv_speed: runner_options.max_recv_speed,
260            max_redirect: runner_options.max_redirect,
261            max_send_speed: runner_options.max_send_speed,
262            negotiate: runner_options.negotiate,
263            netrc: runner_options.netrc,
264            netrc_file: runner_options.netrc_file.clone(),
265            netrc_optional: runner_options.netrc_optional,
266            ntlm: runner_options.ntlm,
267            path_as_is: runner_options.path_as_is,
268            pinned_pub_key: runner_options.pinned_pub_key.clone(),
269            proxy: runner_options.proxy.clone(),
270            no_proxy: runner_options.no_proxy.clone(),
271            insecure: runner_options.insecure,
272            resolves: runner_options.resolves.clone(),
273            ssl_no_revoke: runner_options.ssl_no_revoke,
274            timeout: runner_options.timeout,
275            unix_socket: runner_options.unix_socket.clone(),
276            user: runner_options.user.clone(),
277            user_agent: runner_options.user_agent.clone(),
278            verbosity: match verbosity {
279                Some(Verbosity::Verbose) => Some(http::Verbosity::Verbose),
280                Some(Verbosity::VeryVerbose) => Some(http::Verbosity::VeryVerbose),
281                _ => None,
282            },
283        }
284    }
285}
286
287/// Logs this HTTP `request`.
288fn log_request(
289    http_client: &mut http::Client,
290    curl_cmd: &CurlCmd,
291    request: &http::RequestSpec,
292    logger: &mut Logger,
293) {
294    logger.debug("");
295    logger.debug_important("Cookie store:");
296    for cookie in &http_client.cookie_storage(logger) {
297        logger.debug(&cookie.to_string());
298    }
299
300    logger.debug("");
301    logger.debug_important("Request:");
302    logger.debug(&format!("{} {}", request.method, request.url.raw()));
303    for header in &request.headers {
304        logger.debug(&header.to_string());
305    }
306    if !request.querystring.is_empty() {
307        logger.debug("[QueryStringParams]");
308        for param in &request.querystring {
309            logger.debug(&param.to_string());
310        }
311    }
312    if !request.form.is_empty() {
313        logger.debug("[FormParams]");
314        for param in &request.form {
315            logger.debug(&param.to_string());
316        }
317    }
318    if !request.multipart.is_empty() {
319        logger.debug("[MultipartFormData]");
320        for param in &request.multipart {
321            logger.debug(&param.to_string());
322        }
323    }
324    if !request.cookies.is_empty() {
325        logger.debug("[Cookies]");
326        for cookie in &request.cookies {
327            logger.debug(&cookie.to_string());
328        }
329    }
330    logger.debug("");
331    logger.debug("Request can be run with the following curl command:");
332    logger.debug(&curl_cmd.to_string());
333    logger.debug("");
334}
335
336/// Logs the `captures` from the entry HTTP response.
337fn log_captures(captures: &[CaptureResult], logger: &mut Logger) {
338    if captures.is_empty() {
339        return;
340    }
341    logger.debug_important("Captures:");
342    for c in captures.iter() {
343        logger.capture(&c.name, &c.value);
344    }
345}
346
347/// Warns some deprecation on this `response`.
348fn warn_deprecated(response_spec: &Response, logger: &mut Logger) {
349    if response_spec.asserts().iter().any(|a| {
350        matches!(
351            &a.predicate.predicate_func.value,
352            PredicateFuncValue::Include { .. }
353        )
354    }) {
355        logger.warning("<includes> predicate is now deprecated in favor of <contains> predicate");
356    }
357}