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
13pub struct LoggingConfig {
15 pub target_bucket: String,
16 pub target_prefix: String,
17}
18
19pub fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
22 let le_start = xml.find("<LoggingEnabled>")?;
23 let le_end = xml.find("</LoggingEnabled>")?;
24 let le_body = &xml[le_start + 16..le_end];
25
26 let target_bucket = extract_tag(le_body, "TargetBucket")?;
27 let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
28
29 Some(LoggingConfig {
30 target_bucket,
31 target_prefix,
32 })
33}
34
35pub struct AccessLogRequest<'a> {
37 pub operation: &'a str,
38 pub key: Option<&'a str>,
39 pub status: u16,
40 pub request_id: &'a str,
41 pub method: &'a str,
42 pub path: &'a str,
43}
44
45pub fn format_access_log_entry(
49 bucket_owner: &str,
50 bucket: &str,
51 request: &AccessLogRequest<'_>,
52) -> String {
53 let now = Utc::now();
54 let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
55 let key_str = request.key.unwrap_or("-");
56 let AccessLogRequest {
57 operation,
58 status,
59 request_id,
60 method,
61 path,
62 ..
63 } = request;
64 format!(
66 "{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"
67 )
68}
69
70pub fn maybe_write_access_log(
76 state: &SharedS3State,
77 store: &Arc<dyn S3Store>,
78 source_bucket: &str,
79 request: &AccessLogRequest<'_>,
80) {
81 let logging_config_xml = {
83 let st = state.read();
84 st.buckets
85 .get(source_bucket)
86 .and_then(|b| b.logging_config.clone())
87 };
88
89 let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
90 Some(c) => c,
91 None => return,
92 };
93
94 let bucket_owner = {
95 let st = state.read();
96 st.buckets
97 .get(source_bucket)
98 .map(|b| b.acl_owner_id.clone())
99 .unwrap_or_else(|| "unknown".to_string())
100 };
101
102 let entry = format_access_log_entry(&bucket_owner, source_bucket, request);
103
104 let now = Utc::now();
105 let log_key = format!(
106 "{}{}",
107 config.target_prefix,
108 now.format("%Y-%m-%d-%H-%M-%S-")
109 ) + &Uuid::new_v4().to_string()[..8];
110
111 let data = Bytes::from(entry);
112 let size = data.len() as u64;
113 let etag = format!("{:x}", Md5::digest(&data));
114
115 let log_object = S3Object {
116 key: log_key.clone(),
117 body: crate::state::memory_body(data.clone()),
118 content_type: "text/plain".to_string(),
119 etag,
120 size,
121 last_modified: now,
122 storage_class: "STANDARD".to_string(),
123 ..Default::default()
124 };
125
126 let meta = object_meta_snapshot(&log_object);
127 {
128 let mut st = state.write();
129 if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
130 target.objects.insert(log_key.clone(), log_object);
131 } else {
132 return;
133 }
134 }
135 if let Err(err) = store.put_object(
136 &config.target_bucket,
137 &log_key,
138 None,
139 BodySource::Bytes(data),
140 &meta,
141 ) {
142 tracing::error!(
143 bucket = %config.target_bucket,
144 key = %log_key,
145 error = %err,
146 "failed to persist S3 access log object via store"
147 );
148 }
149}
150
151pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
153 match (method.as_str(), key) {
154 ("GET", None) => "GET.BUCKET",
155 ("GET", Some(_)) => "GET.OBJECT",
156 ("PUT", None) => "PUT.BUCKET",
157 ("PUT", Some(_)) => "PUT.OBJECT",
158 ("DELETE", None) => "DELETE.BUCKET",
159 ("DELETE", Some(_)) => "DELETE.OBJECT",
160 ("HEAD", None) => "HEAD.BUCKET",
161 ("HEAD", Some(_)) => "HEAD.OBJECT",
162 ("POST", _) => "POST",
163 _ => "UNKNOWN",
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn parse_logging_config_enabled() {
173 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
174 <LoggingEnabled>
175 <TargetBucket>log-bucket</TargetBucket>
176 <TargetPrefix>logs/</TargetPrefix>
177 </LoggingEnabled>
178 </BucketLoggingStatus>"#;
179
180 let config = parse_logging_config(xml).unwrap();
181 assert_eq!(config.target_bucket, "log-bucket");
182 assert_eq!(config.target_prefix, "logs/");
183 }
184
185 #[test]
186 fn parse_logging_config_disabled() {
187 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
188 </BucketLoggingStatus>"#;
189
190 assert!(parse_logging_config(xml).is_none());
191 }
192
193 #[test]
194 fn format_log_entry_contains_fields() {
195 let request = AccessLogRequest {
196 operation: "GET.OBJECT",
197 key: Some("my-key.txt"),
198 status: 200,
199 request_id: "req-abc",
200 method: "GET",
201 path: "/my-bucket/my-key.txt",
202 };
203 let entry = format_access_log_entry("owner123", "my-bucket", &request);
204 assert!(entry.contains("owner123"));
205 assert!(entry.contains("my-bucket"));
206 assert!(entry.contains("GET.OBJECT"));
207 assert!(entry.contains("my-key.txt"));
208 assert!(entry.contains("200"));
209 assert!(entry.contains("req-abc"));
210 assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
211 }
212}