Skip to main content

fakecloud_s3/
logging.rs

1use std::sync::Arc;
2
3use bytes::Bytes;
4use chrono::Utc;
5use fakecloud_persistence::{BodySource, S3Store};
6use md5::{Digest, Md5};
7use uuid::Uuid;
8
9use crate::persistence::object_meta_snapshot;
10use crate::state::{S3Object, SharedS3State};
11use crate::xml_util::extract_tag;
12
13/// Whether eager, synchronous delivery of best-effort S3 reports (server
14/// access logs and inventory reports) is enabled.
15///
16/// Real S3 delivers both asynchronously: access logs are best-effort with
17/// minutes-to-hours latency, and inventory reports run on a daily/weekly
18/// schedule. A bucket created and destroyed within a single test therefore
19/// never accumulates these objects, so it deletes cleanly. fakecloud can
20/// deliver them synchronously for users who want to exercise the features,
21/// but that breaks the realistic create/destroy lifecycle (the destination
22/// bucket is never empty), so it is opt-in via
23/// `FAKECLOUD_S3_EAGER_DELIVERY=1`.
24pub fn eager_delivery_enabled() -> bool {
25    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
26    *ENABLED.get_or_init(|| {
27        std::env::var("FAKECLOUD_S3_EAGER_DELIVERY")
28            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
29            .unwrap_or(false)
30    })
31}
32
33/// Parsed logging configuration extracted from the XML stored on the bucket.
34pub struct LoggingConfig {
35    pub target_bucket: String,
36    pub target_prefix: String,
37}
38
39/// Parse a `<BucketLoggingStatus>` XML body into a `LoggingConfig`, if logging
40/// is enabled (i.e. the `<LoggingEnabled>` element is present).
41pub(crate) fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
42    // Locate the closing tag relative to the opening one so a malformed body
43    // (closing before opening, or a duplicated/inverted tag) can't slice with
44    // begin > end and panic — a reachable DoS under eager delivery.
45    let le_content = xml.find("<LoggingEnabled>")? + "<LoggingEnabled>".len();
46    let le_end = xml[le_content..].find("</LoggingEnabled>")? + le_content;
47    let le_body = &xml[le_content..le_end];
48
49    let target_bucket = extract_tag(le_body, "TargetBucket")?;
50    let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
51
52    Some(LoggingConfig {
53        target_bucket,
54        target_prefix,
55    })
56}
57
58/// Everything needed to describe a single S3 request for access logging.
59pub struct AccessLogRequest<'a> {
60    pub operation: &'a str,
61    pub key: Option<&'a str>,
62    pub status: u16,
63    pub request_id: &'a str,
64    pub method: &'a str,
65    pub path: &'a str,
66}
67
68/// Generate an S3 access log line in a format similar to AWS.
69///
70/// See <https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html>
71pub fn format_access_log_entry(
72    bucket_owner: &str,
73    bucket: &str,
74    request: &AccessLogRequest<'_>,
75) -> String {
76    let now = Utc::now();
77    let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
78    let key_str = request.key.unwrap_or("-");
79    let AccessLogRequest {
80        operation,
81        status,
82        request_id,
83        method,
84        path,
85        ..
86    } = request;
87    // Simplified log line matching the AWS format fields
88    format!(
89        "{bucket_owner} {bucket} {time} 127.0.0.1 arn:aws:iam::000000000000:user/testuser {request_id} REST.{operation} {key_str} \"{method} {path} HTTP/1.1\" {status} - - - - - \"-\" \"FakeCloud/1.0\" - - - - -\n"
90    )
91}
92
93/// After a request has been processed, check whether the source bucket has
94/// logging enabled and, if so, write a log entry to the target bucket.
95///
96/// This should be called at the end of the `handle` method so that every S3
97/// operation on a logging-enabled bucket produces a record.
98pub fn maybe_write_access_log(
99    state: &SharedS3State,
100    store: &Arc<dyn S3Store>,
101    source_bucket: &str,
102    request: &AccessLogRequest<'_>,
103) {
104    // Real S3 delivers access logs asynchronously and best-effort, so a bucket
105    // is empty during a quick create/destroy test. Only deliver synchronously
106    // when explicitly opted in.
107    if !eager_delivery_enabled() {
108        return;
109    }
110
111    // Read logging config from the source bucket
112    let (logging_config_xml, bucket_owner) = {
113        let mas = state.read();
114        let acct = match mas.find_account(|s| s.buckets.contains_key(source_bucket)) {
115            Some(a) => a,
116            None => return,
117        };
118        let st = match mas.get(acct) {
119            Some(s) => s,
120            None => return,
121        };
122        let config_xml = st
123            .buckets
124            .get(source_bucket)
125            .and_then(|b| b.logging_config.clone());
126        let owner = st
127            .buckets
128            .get(source_bucket)
129            .map(|b| b.acl_owner_id.clone())
130            .unwrap_or_else(|| "unknown".to_string());
131        (config_xml, owner)
132    };
133
134    let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
135        Some(c) => c,
136        None => return,
137    };
138
139    let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
140
141    let now = Utc::now();
142    let log_key = format!(
143        "{}{}",
144        config.target_prefix,
145        now.format("%Y-%m-%d-%H-%M-%S-")
146    ) + &Uuid::new_v4().to_string()[..8];
147
148    let data = Bytes::from(entry);
149    let size = data.len() as u64;
150    let etag = format!("{:x}", Md5::digest(&data));
151
152    let log_object = S3Object {
153        key: log_key.clone(),
154        body: crate::state::memory_body(data.clone()),
155        content_type: "text/plain".to_string(),
156        etag,
157        size,
158        last_modified: now,
159        storage_class: "STANDARD".to_string(),
160        ..Default::default()
161    };
162
163    let meta = object_meta_snapshot(&log_object);
164    {
165        let mut mas = state.write();
166        let target_acct = mas
167            .find_account(|s| s.buckets.contains_key(&config.target_bucket))
168            .map(|a| a.to_string());
169        let inserted = if let Some(acct) = target_acct {
170            if let Some(st) = mas.get_mut(&acct) {
171                if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
172                    target.objects.insert(log_key.clone(), log_object);
173                    true
174                } else {
175                    false
176                }
177            } else {
178                false
179            }
180        } else {
181            false
182        };
183        if !inserted {
184            return;
185        }
186    }
187    if let Err(err) = store.put_object(
188        &config.target_bucket,
189        &log_key,
190        None,
191        BodySource::Bytes(data),
192        &meta,
193    ) {
194        tracing::error!(
195            bucket = %config.target_bucket,
196            key = %log_key,
197            error = %err,
198            "failed to persist S3 access log object via store"
199        );
200    }
201}
202
203/// Determine the S3 operation name from the HTTP method and key presence.
204pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
205    match (method.as_str(), key) {
206        ("GET", None) => "GET.BUCKET",
207        ("GET", Some(_)) => "GET.OBJECT",
208        ("PUT", None) => "PUT.BUCKET",
209        ("PUT", Some(_)) => "PUT.OBJECT",
210        ("DELETE", None) => "DELETE.BUCKET",
211        ("DELETE", Some(_)) => "DELETE.OBJECT",
212        ("HEAD", None) => "HEAD.BUCKET",
213        ("HEAD", Some(_)) => "HEAD.OBJECT",
214        ("POST", _) => "POST",
215        _ => "UNKNOWN",
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parse_logging_config_enabled() {
225        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
226            <LoggingEnabled>
227                <TargetBucket>log-bucket</TargetBucket>
228                <TargetPrefix>logs/</TargetPrefix>
229            </LoggingEnabled>
230        </BucketLoggingStatus>"#;
231
232        let config = parse_logging_config(xml).unwrap();
233        assert_eq!(config.target_bucket, "log-bucket");
234        assert_eq!(config.target_prefix, "logs/");
235    }
236
237    #[test]
238    fn parse_logging_config_disabled() {
239        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
240        </BucketLoggingStatus>"#;
241
242        assert!(parse_logging_config(xml).is_none());
243    }
244
245    #[test]
246    fn format_log_entry_contains_fields() {
247        let request = AccessLogRequest {
248            operation: "GET.OBJECT",
249            key: Some("my-key.txt"),
250            status: 200,
251            request_id: "req-abc",
252            method: "GET",
253            path: "/my-bucket/my-key.txt",
254        };
255        let entry = format_access_log_entry("owner123", "my-bucket", &request);
256        assert!(entry.contains("owner123"));
257        assert!(entry.contains("my-bucket"));
258        assert!(entry.contains("GET.OBJECT"));
259        assert!(entry.contains("my-key.txt"));
260        assert!(entry.contains("200"));
261        assert!(entry.contains("req-abc"));
262        assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
263    }
264
265    #[test]
266    fn parse_logging_config_missing_target_bucket_returns_none() {
267        let xml = r#"<BucketLoggingStatus>
268            <LoggingEnabled><TargetPrefix>logs/</TargetPrefix></LoggingEnabled>
269        </BucketLoggingStatus>"#;
270        assert!(parse_logging_config(xml).is_none());
271    }
272
273    #[test]
274    fn parse_logging_config_empty_prefix_defaults_to_empty_string() {
275        let xml = r#"<BucketLoggingStatus>
276            <LoggingEnabled><TargetBucket>log</TargetBucket></LoggingEnabled>
277        </BucketLoggingStatus>"#;
278        let cfg = parse_logging_config(xml).unwrap();
279        assert_eq!(cfg.target_bucket, "log");
280        assert_eq!(cfg.target_prefix, "");
281    }
282
283    // A malformed body whose closing tag precedes its opening tag used to
284    // slice with begin > end and panic (a reachable DoS under eager delivery);
285    // it must return None instead.
286    #[test]
287    fn parse_logging_config_inverted_tags_does_not_panic() {
288        let xml = "</LoggingEnabled>garbage<LoggingEnabled>";
289        assert!(parse_logging_config(xml).is_none());
290    }
291
292    #[test]
293    fn format_log_entry_replaces_missing_key_with_dash() {
294        let request = AccessLogRequest {
295            operation: "GET.BUCKET",
296            key: None,
297            status: 200,
298            request_id: "req-x",
299            method: "GET",
300            path: "/my-bucket",
301        };
302        let entry = format_access_log_entry("owner", "my-bucket", &request);
303        assert!(entry.contains("REST.GET.BUCKET - "));
304    }
305
306    #[test]
307    fn operation_name_maps_all_common_methods() {
308        use http::Method;
309        assert_eq!(operation_name(&Method::GET, None), "GET.BUCKET");
310        assert_eq!(operation_name(&Method::GET, Some("k")), "GET.OBJECT");
311        assert_eq!(operation_name(&Method::PUT, None), "PUT.BUCKET");
312        assert_eq!(operation_name(&Method::PUT, Some("k")), "PUT.OBJECT");
313        assert_eq!(operation_name(&Method::DELETE, None), "DELETE.BUCKET");
314        assert_eq!(operation_name(&Method::DELETE, Some("k")), "DELETE.OBJECT");
315        assert_eq!(operation_name(&Method::HEAD, None), "HEAD.BUCKET");
316        assert_eq!(operation_name(&Method::HEAD, Some("k")), "HEAD.OBJECT");
317        assert_eq!(operation_name(&Method::POST, None), "POST");
318        assert_eq!(operation_name(&Method::POST, Some("k")), "POST");
319        assert_eq!(operation_name(&Method::OPTIONS, None), "UNKNOWN");
320    }
321}