ferron/util/
log_placeholders.rs

1use std::collections::HashMap;
2
3use ferron_common::config::ServerConfigurationValue;
4use ferron_common::modules::SocketData;
5use serde_json::{Map, Number, Value};
6
7const DEFAULT_ACCESS_LOG_FORMAT: &str =
8  "{client_ip} - {auth_user} [{timestamp}] \"{method} {path_and_query} {version}\" \
9   {status_code} {content_length} \"{header:Referer}\" \"{header:User-Agent}\"";
10
11fn http_version_to_str(version: hyper::Version) -> &'static str {
12  match version {
13    hyper::Version::HTTP_09 => "HTTP/0.9",
14    hyper::Version::HTTP_10 => "HTTP/1.0",
15    hyper::Version::HTTP_11 => "HTTP/1.1",
16    hyper::Version::HTTP_2 => "HTTP/2.0",
17    hyper::Version::HTTP_3 => "HTTP/3.0",
18    _ => "HTTP/Unknown",
19  }
20}
21
22fn resolve_log_placeholder(
23  placeholder: &str,
24  request_parts: &hyper::http::request::Parts,
25  socket_data: &SocketData,
26  auth_user: Option<&str>,
27  timestamp_str: &str,
28  status_code: u16,
29  content_length: Option<u64>,
30) -> Option<String> {
31  Some(match placeholder {
32    "path" => request_parts.uri.path().to_string(),
33    "path_and_query" => request_parts
34      .uri
35      .path_and_query()
36      .map_or_else(|| request_parts.uri.path().to_string(), |p| p.as_str().to_string()),
37    "method" => request_parts.method.as_str().to_string(),
38    "version" => http_version_to_str(request_parts.version).to_string(),
39    "scheme" => {
40      if socket_data.encrypted {
41        "https".to_string()
42      } else {
43        "http".to_string()
44      }
45    }
46    "client_ip" => socket_data.remote_addr.ip().to_string(),
47    "client_port" => socket_data.remote_addr.port().to_string(),
48    "client_ip_canonical" => socket_data.remote_addr.ip().to_canonical().to_string(),
49    "server_ip" => socket_data.local_addr.ip().to_string(),
50    "server_port" => socket_data.local_addr.port().to_string(),
51    "server_ip_canonical" => socket_data.local_addr.ip().to_canonical().to_string(),
52    "auth_user" => auth_user.unwrap_or("-").to_string(),
53    "timestamp" => timestamp_str.to_string(),
54    "status_code" => status_code.to_string(),
55    "content_length" => content_length.map_or_else(|| "-".to_string(), |len| len.to_string()),
56    _ => {
57      if let Some(header_name) = placeholder.strip_prefix("header:") {
58        if let Some(header_value) = request_parts.headers.get(header_name) {
59          header_value.to_str().unwrap_or("").to_string()
60        } else {
61          "-".to_string()
62        }
63      } else {
64        return None;
65      }
66    }
67  })
68}
69
70pub fn replace_log_placeholders(
71  input: &str,
72  request_parts: &hyper::http::request::Parts,
73  socket_data: &SocketData,
74  auth_user: Option<&str>,
75  timestamp_str: &str,
76  status_code: u16,
77  content_length: Option<u64>,
78) -> String {
79  let mut output = String::new();
80  let mut index_rb_saved = 0;
81  loop {
82    let index_lb = input[index_rb_saved..].find("{");
83    if let Some(index_lb) = index_lb {
84      let index_rb_afterlb = input[index_rb_saved + index_lb + 1..].find("}");
85      if let Some(index_rb_afterlb) = index_rb_afterlb {
86        let index_rb = index_rb_afterlb + index_lb + 1;
87        let placeholder_value = &input[index_rb_saved + index_lb + 1..index_rb_saved + index_rb];
88        output.push_str(&input[index_rb_saved..index_rb_saved + index_lb]);
89        if let Some(value) = resolve_log_placeholder(
90          placeholder_value,
91          request_parts,
92          socket_data,
93          auth_user,
94          timestamp_str,
95          status_code,
96          content_length,
97        ) {
98          output.push_str(&value);
99        } else {
100          // Unknown placeholder, leave it as is
101          output.push('{');
102          output.push_str(placeholder_value);
103          output.push('}');
104        }
105        if index_rb < input.len() - 1 {
106          index_rb_saved += index_rb + 1;
107        } else {
108          break;
109        }
110      } else {
111        output.push_str(&input[index_rb_saved..]);
112      }
113    } else {
114      output.push_str(&input[index_rb_saved..]);
115      break;
116    }
117  }
118  output
119}
120
121#[allow(clippy::too_many_arguments)]
122pub fn generate_access_log_message(
123  request_parts: &hyper::http::request::Parts,
124  socket_data: &SocketData,
125  auth_user: Option<&str>,
126  timestamp_str: &str,
127  status_code: u16,
128  content_length: Option<u64>,
129  log_format: Option<&str>,
130  log_json_props: Option<&HashMap<String, ServerConfigurationValue>>,
131) -> String {
132  if let Some(log_json_props) = log_json_props {
133    let mut log_entry = Map::new();
134    log_entry.insert(
135      "auth_user".to_string(),
136      auth_user.map_or(Value::Null, |user| Value::String(user.to_string())),
137    );
138    log_entry.insert(
139      "client_ip".to_string(),
140      Value::String(socket_data.remote_addr.ip().to_string()),
141    );
142    log_entry.insert(
143      "content_length".to_string(),
144      content_length.map_or(Value::Null, |len| Value::Number(Number::from(len))),
145    );
146    log_entry.insert(
147      "method".to_string(),
148      Value::String(request_parts.method.as_str().to_string()),
149    );
150    log_entry.insert(
151      "path_and_query".to_string(),
152      Value::String(
153        request_parts
154          .uri
155          .path_and_query()
156          .map_or_else(|| request_parts.uri.path().to_string(), |p| p.as_str().to_string()),
157      ),
158    );
159    log_entry.insert("status_code".to_string(), Value::Number(Number::from(status_code)));
160    log_entry.insert("timestamp".to_string(), Value::String(timestamp_str.to_string()));
161    log_entry.insert(
162      "version".to_string(),
163      Value::String(http_version_to_str(request_parts.version).to_string()),
164    );
165    log_entry.insert(
166      "referer".to_string(),
167      request_parts
168        .headers
169        .get("Referer")
170        .and_then(|value| value.to_str().ok())
171        .map_or(Value::Null, |value| Value::String(value.to_string())),
172    );
173    log_entry.insert(
174      "user_agent".to_string(),
175      request_parts
176        .headers
177        .get("User-Agent")
178        .and_then(|value| value.to_str().ok())
179        .map_or(Value::Null, |value| Value::String(value.to_string())),
180    );
181
182    for (property_name, property_value) in log_json_props {
183      if let Some(property_template) = property_value.as_str() {
184        log_entry.insert(
185          property_name.clone(),
186          Value::String(replace_log_placeholders(
187            property_template,
188            request_parts,
189            socket_data,
190            auth_user,
191            timestamp_str,
192            status_code,
193            content_length,
194          )),
195        );
196      }
197    }
198
199    Value::Object(log_entry).to_string()
200  } else {
201    replace_log_placeholders(
202      log_format.unwrap_or(DEFAULT_ACCESS_LOG_FORMAT),
203      request_parts,
204      socket_data,
205      auth_user,
206      timestamp_str,
207      status_code,
208      content_length,
209    )
210  }
211}
212
213#[cfg(test)]
214mod tests {
215  use super::*;
216  use ferron_common::config::ServerConfigurationValue;
217  use hyper::header::HeaderName;
218  use hyper::http::{request::Parts, Method, Version};
219  use hyper::Request;
220  use serde_json::json;
221
222  fn make_parts(uri_str: &str, method: Method, version: Version, headers: Option<Vec<(&str, &str)>>) -> Parts {
223    let mut parts = Request::builder()
224      .uri(uri_str)
225      .method(method)
226      .version(version)
227      .body(())
228      .unwrap()
229      .into_parts()
230      .0;
231
232    if let Some(hdrs) = headers {
233      for (k, v) in hdrs {
234        parts
235          .headers
236          .insert(k.parse::<HeaderName>().unwrap(), v.parse().unwrap());
237      }
238    }
239    parts
240  }
241
242  #[test]
243  fn test_basic_placeholders() {
244    let parts = make_parts("/some/path", Method::GET, Version::HTTP_11, None);
245    let input = "Path: {path}, Method: {method}, Version: {version}";
246    let expected = "Path: /some/path, Method: GET, Version: HTTP/1.1";
247    let output = replace_log_placeholders(
248      input,
249      &parts,
250      &SocketData {
251        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
252        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
253        encrypted: false,
254      },
255      None,
256      "06/Oct/2025:15:12:51 +0200",
257      200,
258      None,
259    );
260    assert_eq!(output, expected);
261  }
262
263  #[test]
264  fn test_header_placeholder() {
265    let parts = make_parts(
266      "/test",
267      Method::POST,
268      Version::HTTP_2,
269      Some(vec![("User-Agent", "MyApp/1.0")]),
270    );
271    let input = "Header: {header:User-Agent}";
272    let expected = "Header: MyApp/1.0";
273    let output = replace_log_placeholders(
274      input,
275      &parts,
276      &SocketData {
277        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
278        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
279        encrypted: false,
280      },
281      None,
282      "06/Oct/2025:15:12:51 +0200",
283      200,
284      None,
285    );
286    assert_eq!(output, expected);
287  }
288
289  #[test]
290  fn test_missing_header() {
291    let parts = make_parts("/", Method::GET, Version::HTTP_11, None);
292    let input = "Header: {header:Missing}";
293    let expected = "Header: -";
294    let output = replace_log_placeholders(
295      input,
296      &parts,
297      &SocketData {
298        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
299        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
300        encrypted: false,
301      },
302      None,
303      "06/Oct/2025:15:12:51 +0200",
304      200,
305      None,
306    );
307    assert_eq!(output, expected);
308  }
309
310  #[test]
311  fn test_unknown_placeholder() {
312    let parts = make_parts("/", Method::GET, Version::HTTP_11, None);
313    let input = "Unknown: {foo}";
314    let expected = "Unknown: {foo}";
315    let output = replace_log_placeholders(
316      input,
317      &parts,
318      &SocketData {
319        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
320        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
321        encrypted: false,
322      },
323      None,
324      "06/Oct/2025:15:12:51 +0200",
325      200,
326      None,
327    );
328    assert_eq!(output, expected);
329  }
330
331  #[test]
332  fn test_no_placeholders() {
333    let parts = make_parts("/", Method::GET, Version::HTTP_11, None);
334    let input = "Static string with no placeholders.";
335    let output = replace_log_placeholders(
336      input,
337      &parts,
338      &SocketData {
339        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
340        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
341        encrypted: false,
342      },
343      None,
344      "06/Oct/2025:15:12:51 +0200",
345      200,
346      None,
347    );
348    assert_eq!(output, input);
349  }
350
351  #[test]
352  fn test_multiple_placeholders() {
353    let parts = make_parts(
354      "/data",
355      Method::PUT,
356      Version::HTTP_2,
357      Some(vec![("Content-Type", "application/json"), ("Host", "api.example.com")]),
358    );
359    let input = "{method} {path} {version} Host: {header:Host} Content-Type: {header:Content-Type}";
360    let expected = "PUT /data HTTP/2.0 Host: api.example.com Content-Type: application/json";
361    let output = replace_log_placeholders(
362      input,
363      &parts,
364      &SocketData {
365        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
366        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
367        encrypted: false,
368      },
369      None,
370      "06/Oct/2025:15:12:51 +0200",
371      200,
372      None,
373    );
374    assert_eq!(output, expected);
375  }
376
377  #[test]
378  fn test_log_placeholders() {
379    let parts = make_parts(
380      "/data",
381      Method::PUT,
382      Version::HTTP_2,
383      Some(vec![("Content-Type", "application/json"), ("Host", "api.example.com")]),
384    );
385    let input = "[{timestamp}] {auth_user} {status_code} {content_length}";
386    let expected = "[06/Oct/2025:15:12:51 +0200] - 200 -";
387    let output = replace_log_placeholders(
388      input,
389      &parts,
390      &SocketData {
391        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
392        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
393        encrypted: false,
394      },
395      None,
396      "06/Oct/2025:15:12:51 +0200",
397      200,
398      None,
399    );
400    assert_eq!(output, expected);
401  }
402
403  #[test]
404  fn test_generate_access_log_message_plain_text() {
405    let parts = make_parts("/test?hello=world", Method::GET, Version::HTTP_11, None);
406    let output = generate_access_log_message(
407      &parts,
408      &SocketData {
409        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
410        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
411        encrypted: false,
412      },
413      None,
414      "06/Oct/2025:15:12:51 +0200",
415      200,
416      Some(123),
417      None,
418      None,
419    );
420    assert_eq!(
421      output,
422      "127.0.0.1 - - [06/Oct/2025:15:12:51 +0200] \"GET /test?hello=world HTTP/1.1\" 200 123 \"-\" \"-\""
423    );
424  }
425
426  #[test]
427  fn test_generate_access_log_message_json_with_extra_props() {
428    let parts = make_parts(
429      "/api/items?id=1",
430      Method::POST,
431      Version::HTTP_2,
432      Some(vec![
433        ("Referer", "https://example.com/app"),
434        ("User-Agent", "FerronTest/1.0"),
435        ("X-Request-Id", "req-123"),
436      ]),
437    );
438    let mut extra_props = HashMap::new();
439    extra_props.insert(
440      "request_id".to_string(),
441      ServerConfigurationValue::String("{header:X-Request-Id}".to_string()),
442    );
443    extra_props.insert(
444      "request_target".to_string(),
445      ServerConfigurationValue::String("{method} {path_and_query}".to_string()),
446    );
447
448    let output = generate_access_log_message(
449      &parts,
450      &SocketData {
451        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
452        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 443)),
453        encrypted: true,
454      },
455      Some("alice"),
456      "06/Oct/2025:15:12:51 +0200",
457      201,
458      Some(456),
459      Some("{method} {path_and_query}"),
460      Some(&extra_props),
461    );
462
463    let output: Value = serde_json::from_str(&output).unwrap();
464    assert_eq!(
465      output,
466      json!({
467        "auth_user": "alice",
468        "client_ip": "127.0.0.1",
469        "content_length": 456,
470        "method": "POST",
471        "path_and_query": "/api/items?id=1",
472        "referer": "https://example.com/app",
473        "request_id": "req-123",
474        "request_target": "POST /api/items?id=1",
475        "status_code": 201,
476        "timestamp": "06/Oct/2025:15:12:51 +0200",
477        "user_agent": "FerronTest/1.0",
478        "version": "HTTP/2.0"
479      })
480    );
481  }
482
483  #[test]
484  fn test_generate_access_log_message_json_can_override_default_fields() {
485    let parts = make_parts("/health", Method::GET, Version::HTTP_11, None);
486    let mut extra_props = HashMap::new();
487    extra_props.insert(
488      "status_code".to_string(),
489      ServerConfigurationValue::String("ok".to_string()),
490    );
491
492    let output = generate_access_log_message(
493      &parts,
494      &SocketData {
495        remote_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 40000)),
496        local_addr: std::net::SocketAddr::V4(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 80)),
497        encrypted: false,
498      },
499      None,
500      "06/Oct/2025:15:12:51 +0200",
501      200,
502      None,
503      None,
504      Some(&extra_props),
505    );
506
507    let output: Value = serde_json::from_str(&output).unwrap();
508    assert_eq!(output["status_code"], "ok");
509    assert_eq!(output["content_length"], Value::Null);
510    assert_eq!(output["auth_user"], Value::Null);
511  }
512}