1use bytes::Bytes;
2use chrono::Utc;
3use md5::{Digest, Md5};
4use uuid::Uuid;
5
6use crate::state::{S3Object, SharedS3State};
7
8pub struct LoggingConfig {
10 pub target_bucket: String,
11 pub target_prefix: String,
12}
13
14pub fn parse_logging_config(xml: &str) -> Option<LoggingConfig> {
17 let le_start = xml.find("<LoggingEnabled>")?;
18 let le_end = xml.find("</LoggingEnabled>")?;
19 let le_body = &xml[le_start + 16..le_end];
20
21 let target_bucket = extract_tag(le_body, "TargetBucket")?;
22 let target_prefix = extract_tag(le_body, "TargetPrefix").unwrap_or_default();
23
24 Some(LoggingConfig {
25 target_bucket,
26 target_prefix,
27 })
28}
29
30#[allow(clippy::too_many_arguments)]
34pub fn format_access_log_entry(
35 bucket_owner: &str,
36 bucket: &str,
37 operation: &str,
38 key: Option<&str>,
39 status: u16,
40 request_id: &str,
41 method: &str,
42 path: &str,
43) -> String {
44 let now = Utc::now();
45 let time = now.format("[%d/%b/%Y:%H:%M:%S %z]");
46 let key_str = key.unwrap_or("-");
47 format!(
49 "{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"
50 )
51}
52
53#[allow(clippy::too_many_arguments)]
59pub fn maybe_write_access_log(
60 state: &SharedS3State,
61 source_bucket: &str,
62 operation: &str,
63 key: Option<&str>,
64 status: u16,
65 request_id: &str,
66 method: &str,
67 path: &str,
68) {
69 let logging_config_xml = {
71 let st = state.read();
72 st.buckets
73 .get(source_bucket)
74 .and_then(|b| b.logging_config.clone())
75 };
76
77 let config = match logging_config_xml.and_then(|xml| parse_logging_config(&xml)) {
78 Some(c) => c,
79 None => return,
80 };
81
82 let bucket_owner = {
83 let st = state.read();
84 st.buckets
85 .get(source_bucket)
86 .map(|b| b.acl_owner_id.clone())
87 .unwrap_or_else(|| "unknown".to_string())
88 };
89
90 let entry = format_access_log_entry(
91 &bucket_owner,
92 source_bucket,
93 operation,
94 key,
95 status,
96 request_id,
97 method,
98 path,
99 );
100
101 let now = Utc::now();
102 let log_key = format!(
103 "{}{}",
104 config.target_prefix,
105 now.format("%Y-%m-%d-%H-%M-%S-")
106 ) + &Uuid::new_v4().to_string()[..8];
107
108 let data = Bytes::from(entry);
109 let size = data.len() as u64;
110 let etag = format!("{:x}", Md5::digest(&data));
111
112 let log_object = S3Object {
113 key: log_key.clone(),
114 data,
115 content_type: "text/plain".to_string(),
116 etag,
117 size,
118 last_modified: now,
119 metadata: Default::default(),
120 storage_class: "STANDARD".to_string(),
121 tags: Default::default(),
122 acl_grants: vec![],
123 acl_owner_id: None,
124 parts_count: None,
125 part_sizes: None,
126 sse_algorithm: None,
127 sse_kms_key_id: None,
128 bucket_key_enabled: None,
129 version_id: None,
130 is_delete_marker: false,
131 content_encoding: None,
132 website_redirect_location: None,
133 restore_ongoing: None,
134 restore_expiry: None,
135 checksum_algorithm: None,
136 checksum_value: None,
137 lock_mode: None,
138 lock_retain_until: None,
139 lock_legal_hold: None,
140 };
141
142 let mut st = state.write();
143 if let Some(target) = st.buckets.get_mut(&config.target_bucket) {
144 target.objects.insert(log_key, log_object);
145 }
146}
147
148pub fn operation_name(method: &http::Method, key: Option<&str>) -> &'static str {
150 match (method.as_str(), key) {
151 ("GET", None) => "GET.BUCKET",
152 ("GET", Some(_)) => "GET.OBJECT",
153 ("PUT", None) => "PUT.BUCKET",
154 ("PUT", Some(_)) => "PUT.OBJECT",
155 ("DELETE", None) => "DELETE.BUCKET",
156 ("DELETE", Some(_)) => "DELETE.OBJECT",
157 ("HEAD", None) => "HEAD.BUCKET",
158 ("HEAD", Some(_)) => "HEAD.OBJECT",
159 ("POST", _) => "POST",
160 _ => "UNKNOWN",
161 }
162}
163
164fn extract_tag(body: &str, tag: &str) -> Option<String> {
165 let open = format!("<{tag}>");
166 let close = format!("</{tag}>");
167 let start = body.find(&open)?;
168 let content_start = start + open.len();
169 let end = body[content_start..].find(&close)?;
170 Some(body[content_start..content_start + end].trim().to_string())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn parse_logging_config_enabled() {
179 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
180 <LoggingEnabled>
181 <TargetBucket>log-bucket</TargetBucket>
182 <TargetPrefix>logs/</TargetPrefix>
183 </LoggingEnabled>
184 </BucketLoggingStatus>"#;
185
186 let config = parse_logging_config(xml).unwrap();
187 assert_eq!(config.target_bucket, "log-bucket");
188 assert_eq!(config.target_prefix, "logs/");
189 }
190
191 #[test]
192 fn parse_logging_config_disabled() {
193 let xml = r#"<BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
194 </BucketLoggingStatus>"#;
195
196 assert!(parse_logging_config(xml).is_none());
197 }
198
199 #[test]
200 fn format_log_entry_contains_fields() {
201 let entry = format_access_log_entry(
202 "owner123",
203 "my-bucket",
204 "GET.OBJECT",
205 Some("my-key.txt"),
206 200,
207 "req-abc",
208 "GET",
209 "/my-bucket/my-key.txt",
210 );
211 assert!(entry.contains("owner123"));
212 assert!(entry.contains("my-bucket"));
213 assert!(entry.contains("GET.OBJECT"));
214 assert!(entry.contains("my-key.txt"));
215 assert!(entry.contains("200"));
216 assert!(entry.contains("req-abc"));
217 assert!(entry.contains("\"GET /my-bucket/my-key.txt HTTP/1.1\""));
218 }
219}