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    let le_start = xml.find("<LoggingEnabled>")?;
43    let le_end = xml.find("</LoggingEnabled>")?;
44    let le_body = &xml[le_start + 16..le_end];
45
46    let target_bucket = extract_tag(le_body, "TargetBucket")?;
47    let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
48
49    Some(LoggingConfig {
50        target_bucket,
51        target_prefix,
52    })
53}
54
55/// Everything needed to describe a single S3 request for access logging.
56pub struct AccessLogRequest<'a> {
57    pub operation: &'a str,
58    pub key: Option<&'a str>,
59    pub status: u16,
60    pub request_id: &'a str,
61    pub method: &'a str,
62    pub path: &'a str,
63}
64
65/// Generate an S3 access log line in a format similar to AWS.
66///
67/// See <https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html>
68pub fn format_access_log_entry(
69    bucket_owner: &str,
70    bucket: &str,
71    request: &AccessLogRequest<'_>,
72) -> String {
73    let now = Utc::now();
74    let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
75    let key_str = request.key.unwrap_or("-");
76    let AccessLogRequest {
77        operation,
78        status,
79        request_id,
80        method,
81        path,
82        ..
83    } = request;
84    // Simplified log line matching the AWS format fields
85    format!(
86        "{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"
87    )
88}
89
90/// After a request has been processed, check whether the source bucket has
91/// logging enabled and, if so, write a log entry to the target bucket.
92///
93/// This should be called at the end of the `handle` method so that every S3
94/// operation on a logging-enabled bucket produces a record.
95pub fn maybe_write_access_log(
96    state: &SharedS3State,
97    store: &Arc<dyn S3Store>,
98    source_bucket: &str,
99    request: &AccessLogRequest<'_>,
100) {
101    // Real S3 delivers access logs asynchronously and best-effort, so a bucket
102    // is empty during a quick create/destroy test. Only deliver synchronously
103    // when explicitly opted in.
104    if !eager_delivery_enabled() {
105        return;
106    }
107
108    // Read logging config from the source bucket
109    let (logging_config_xml, bucket_owner) = {
110        let mas = state.read();
111        let acct = match mas.find_account(|s| s.buckets.contains_key(source_bucket)) {
112            Some(a) => a,
113            None => return,
114        };
115        let st = match mas.get(acct) {
116            Some(s) => s,
117            None => return,
118        };
119        let config_xml = st
120            .buckets
121            .get(source_bucket)
122            .and_then(|b| b.logging_config.clone());
123        let owner = st
124            .buckets
125            .get(source_bucket)
126            .map(|b| b.acl_owner_id.clone())
127            .unwrap_or_else(|| "unknown".to_string());
128        (config_xml, owner)
129    };
130
131    let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
132        Some(c) => c,
133        None => return,
134    };
135
136    let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
137
138    let now = Utc::now();
139    let log_key = format!(
140        "{}{}",
141        config.target_prefix,
142        now.format("%Y-%m-%d-%H-%M-%S-")
143    ) + &Uuid::new_v4().to_string()[..8];
144
145    let data = Bytes::from(entry);
146    let size = data.len() as u64;
147    let etag = format!("{:x}", Md5::digest(&data));
148
149    let log_object = S3Object {
150        key: log_key.clone(),
151        body: crate::state::memory_body(data.clone()),
152        content_type: "text/plain".to_string(),
153        etag,
154        size,
155        last_modified: now,
156        storage_class: "STANDARD".to_string(),
157        ..Default::default()
158    };
159
160    let meta = object_meta_snapshot(&log_object);
161    {
162        let mut mas = state.write();
163        let target_acct = mas
164            .find_account(|s| s.buckets.contains_key(&config.target_bucket))
165            .map(|a| a.to_string());
166        let inserted = if let Some(acct) = target_acct {
167            if let Some(st) = mas.get_mut(&acct) {
168                if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
169                    target.objects.insert(log_key.clone(), log_object);
170                    true
171                } else {
172                    false
173                }
174            } else {
175                false
176            }
177        } else {
178            false
179        };
180        if !inserted {
181            return;
182        }
183    }
184    if let Err(err) = store.put_object(
185        &config.target_bucket,
186        &log_key,
187        None,
188        BodySource::Bytes(data),
189        &meta,
190    ) {
191        tracing::error!(
192            bucket = %config.target_bucket,
193            key = %log_key,
194            error = %err,
195            "failed to persist S3 access log object via store"
196        );
197    }
198}
199
200/// Determine the S3 operation name from the HTTP method and key presence.
201pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
202    match (method.as_str(), key) {
203        ("GET", None) => "GET.BUCKET",
204        ("GET", Some(_)) => "GET.OBJECT",
205        ("PUT", None) => "PUT.BUCKET",
206        ("PUT", Some(_)) => "PUT.OBJECT",
207        ("DELETE", None) => "DELETE.BUCKET",
208        ("DELETE", Some(_)) => "DELETE.OBJECT",
209        ("HEAD", None) => "HEAD.BUCKET",
210        ("HEAD", Some(_)) => "HEAD.OBJECT",
211        ("POST", _) => "POST",
212        _ => "UNKNOWN",
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn parse_logging_config_enabled() {
222        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
223            <LoggingEnabled>
224                <TargetBucket>log-bucket</TargetBucket>
225                <TargetPrefix>logs/</TargetPrefix>
226            </LoggingEnabled>
227        </BucketLoggingStatus>"#;
228
229        let config = parse_logging_config(xml).unwrap();
230        assert_eq!(config.target_bucket, "log-bucket");
231        assert_eq!(config.target_prefix, "logs/");
232    }
233
234    #[test]
235    fn parse_logging_config_disabled() {
236        let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
237        </BucketLoggingStatus>"#;
238
239        assert!(parse_logging_config(xml).is_none());
240    }
241
242    #[test]
243    fn format_log_entry_contains_fields() {
244        let request = AccessLogRequest {
245            operation: "GET.OBJECT",
246            key: Some("my-key.txt"),
247            status: 200,
248            request_id: "req-abc",
249            method: "GET",
250            path: "/my-bucket/my-key.txt",
251        };
252        let entry = format_access_log_entry("owner123", "my-bucket", &request);
253        assert!(entry.contains("owner123"));
254        assert!(entry.contains("my-bucket"));
255        assert!(entry.contains("GET.OBJECT"));
256        assert!(entry.contains("my-key.txt"));
257        assert!(entry.contains("200"));
258        assert!(entry.contains("req-abc"));
259        assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
260    }
261
262    #[test]
263    fn parse_logging_config_missing_target_bucket_returns_none() {
264        let xml = r#"<BucketLoggingStatus>
265            <LoggingEnabled><TargetPrefix>logs/</TargetPrefix></LoggingEnabled>
266        </BucketLoggingStatus>"#;
267        assert!(parse_logging_config(xml).is_none());
268    }
269
270    #[test]
271    fn parse_logging_config_empty_prefix_defaults_to_empty_string() {
272        let xml = r#"<BucketLoggingStatus>
273            <LoggingEnabled><TargetBucket>log</TargetBucket></LoggingEnabled>
274        </BucketLoggingStatus>"#;
275        let cfg = parse_logging_config(xml).unwrap();
276        assert_eq!(cfg.target_bucket, "log");
277        assert_eq!(cfg.target_prefix, "");
278    }
279
280    #[test]
281    fn format_log_entry_replaces_missing_key_with_dash() {
282        let request = AccessLogRequest {
283            operation: "GET.BUCKET",
284            key: None,
285            status: 200,
286            request_id: "req-x",
287            method: "GET",
288            path: "/my-bucket",
289        };
290        let entry = format_access_log_entry("owner", "my-bucket", &request);
291        assert!(entry.contains("REST.GET.BUCKET - "));
292    }
293
294    #[test]
295    fn operation_name_maps_all_common_methods() {
296        use http::Method;
297        assert_eq!(operation_name(&Method::GET, None), "GET.BUCKET");
298        assert_eq!(operation_name(&Method::GET, Some("k")), "GET.OBJECT");
299        assert_eq!(operation_name(&Method::PUT, None), "PUT.BUCKET");
300        assert_eq!(operation_name(&Method::PUT, Some("k")), "PUT.OBJECT");
301        assert_eq!(operation_name(&Method::DELETE, None), "DELETE.BUCKET");
302        assert_eq!(operation_name(&Method::DELETE, Some("k")), "DELETE.OBJECT");
303        assert_eq!(operation_name(&Method::HEAD, None), "HEAD.BUCKET");
304        assert_eq!(operation_name(&Method::HEAD, Some("k")), "HEAD.OBJECT");
305        assert_eq!(operation_name(&Method::POST, None), "POST");
306        assert_eq!(operation_name(&Method::POST, Some("k")), "POST");
307        assert_eq!(operation_name(&Method::OPTIONS, None), "UNKNOWN");
308    }
309}