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 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
33pub struct LoggingConfig {
35 pub target_bucket: String,
36 pub target_prefix: String,
37}
38
39pub(crate) fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
42 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
58pub 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
68pub 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 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
93pub fn maybe_write_access_log(
99 state: &SharedS3State,
100 store: &Arc<dyn S3Store>,
101 source_bucket: &str,
102 request: &AccessLogRequest<'_>,
103) {
104 if !eager_delivery_enabled() {
108 return;
109 }
110
111 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
203pub 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 #[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}