1use std::collections::HashMap;
39use std::sync::RwLock;
40
41use serde::{Deserialize, Serialize};
42
43pub const MAX_TAGS_PER_OBJECT: usize = 10;
45pub const MAX_TAG_KEY_BYTES: usize = 128;
47pub const MAX_TAG_VALUE_BYTES: usize = 256;
49
50#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct TagSet(pub Vec<(String, String)>);
56
57impl TagSet {
58 #[must_use]
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 pub fn from_pairs(pairs: Vec<(String, String)>) -> Result<Self, TagError> {
68 let s = Self(pairs);
69 s.validate()?;
70 Ok(s)
71 }
72
73 #[must_use]
75 pub fn get(&self, key: &str) -> Option<&str> {
76 self.0
77 .iter()
78 .rev()
79 .find(|(k, _)| k == key)
80 .map(|(_, v)| v.as_str())
81 }
82
83 pub fn iter(&self) -> impl Iterator<Item = &(String, String)> {
85 self.0.iter()
86 }
87
88 #[must_use]
89 pub fn len(&self) -> usize {
90 self.0.len()
91 }
92
93 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.0.is_empty()
96 }
97
98 pub fn validate(&self) -> Result<(), TagError> {
109 if self.0.len() > MAX_TAGS_PER_OBJECT {
110 return Err(TagError::TooManyTags {
111 got: self.0.len(),
112 max: MAX_TAGS_PER_OBJECT,
113 });
114 }
115 let mut seen: std::collections::HashSet<&str> =
116 std::collections::HashSet::with_capacity(self.0.len());
117 for (k, v) in &self.0 {
118 if k.is_empty() {
119 return Err(TagError::EmptyKey);
120 }
121 if k.len() > MAX_TAG_KEY_BYTES {
122 return Err(TagError::KeyTooLong {
123 len: k.len(),
124 max: MAX_TAG_KEY_BYTES,
125 });
126 }
127 if v.len() > MAX_TAG_VALUE_BYTES {
128 return Err(TagError::ValueTooLong {
129 key: k.clone(),
130 len: v.len(),
131 max: MAX_TAG_VALUE_BYTES,
132 });
133 }
134 if !seen.insert(k.as_str()) {
135 return Err(TagError::DuplicateKey { key: k.clone() });
136 }
137 }
138 Ok(())
139 }
140}
141
142#[derive(Debug, thiserror::Error)]
148pub enum TagError {
149 #[error("too many tags: {got} (max {max} per object/bucket)")]
150 TooManyTags { got: usize, max: usize },
151 #[error("tag key must not be empty")]
152 EmptyKey,
153 #[error("tag key too long: {len} bytes (max {max})")]
154 KeyTooLong { len: usize, max: usize },
155 #[error("tag value too long: key {key:?} value is {len} bytes (max {max})")]
156 ValueTooLong { key: String, len: usize, max: usize },
157 #[error("duplicate tag key: {key:?}")]
158 DuplicateKey { key: String },
159 #[error("invalid tag header (URL-encoded): {0}")]
160 InvalidHeader(String),
161}
162
163#[derive(Debug, Default, Serialize, Deserialize)]
166struct TagSnapshot {
167 objects: Vec<((String, String), TagSet)>,
168 buckets: HashMap<String, TagSet>,
169}
170
171#[derive(Debug, Default)]
175pub struct TagManager {
176 objects: RwLock<HashMap<(String, String), TagSet>>,
178 buckets: RwLock<HashMap<String, TagSet>>,
180}
181
182impl TagManager {
183 #[must_use]
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 pub fn put_object_tags(&self, bucket: &str, key: &str, tags: TagSet) {
192 crate::lock_recovery::recover_write(&self.objects, "tagging.objects")
193 .insert((bucket.to_owned(), key.to_owned()), tags);
194 }
195
196 #[must_use]
199 pub fn get_object_tags(&self, bucket: &str, key: &str) -> Option<TagSet> {
200 crate::lock_recovery::recover_read(&self.objects, "tagging.objects")
201 .get(&(bucket.to_owned(), key.to_owned()))
202 .cloned()
203 }
204
205 pub fn delete_object_tags(&self, bucket: &str, key: &str) {
208 crate::lock_recovery::recover_write(&self.objects, "tagging.objects")
209 .remove(&(bucket.to_owned(), key.to_owned()));
210 }
211
212 pub fn put_bucket_tags(&self, bucket: &str, tags: TagSet) {
214 crate::lock_recovery::recover_write(&self.buckets, "tagging.buckets")
215 .insert(bucket.to_owned(), tags);
216 }
217
218 #[must_use]
220 pub fn get_bucket_tags(&self, bucket: &str) -> Option<TagSet> {
221 crate::lock_recovery::recover_read(&self.buckets, "tagging.buckets")
222 .get(bucket)
223 .cloned()
224 }
225
226 pub fn delete_bucket_tags(&self, bucket: &str) {
228 crate::lock_recovery::recover_write(&self.buckets, "tagging.buckets").remove(bucket);
229 }
230
231 pub fn to_json(&self) -> Result<String, serde_json::Error> {
234 let objects: Vec<((String, String), TagSet)> =
235 crate::lock_recovery::recover_read(&self.objects, "tagging.objects")
236 .iter()
237 .map(|(k, v)| (k.clone(), v.clone()))
238 .collect();
239 let buckets = crate::lock_recovery::recover_read(&self.buckets, "tagging.buckets").clone();
240 let snap = TagSnapshot { objects, buckets };
241 serde_json::to_string(&snap)
242 }
243
244 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
246 let snap: TagSnapshot = serde_json::from_str(s)?;
247 let mut objects = HashMap::with_capacity(snap.objects.len());
248 for (k, v) in snap.objects {
249 objects.insert(k, v);
250 }
251 Ok(Self {
252 objects: RwLock::new(objects),
253 buckets: RwLock::new(snap.buckets),
254 })
255 }
256}
257
258pub fn parse_tagging_header(header: &str) -> Result<TagSet, TagError> {
269 let trimmed = header.trim();
270 if trimmed.is_empty() {
271 return Ok(TagSet::new());
272 }
273 let mut pairs = Vec::new();
274 for part in trimmed.split('&') {
275 if part.is_empty() {
276 continue;
277 }
278 let (raw_k, raw_v) = match part.split_once('=') {
279 Some((k, v)) => (k, v),
280 None => (part, ""),
281 };
282 let k = url_decode(raw_k)
283 .map_err(|e| TagError::InvalidHeader(format!("key {raw_k:?}: {e}")))?;
284 let v = url_decode(raw_v)
285 .map_err(|e| TagError::InvalidHeader(format!("value {raw_v:?}: {e}")))?;
286 pairs.push((k, v));
287 }
288 TagSet::from_pairs(pairs)
289}
290
291#[must_use]
295pub fn render_tagging_header(tags: &TagSet) -> String {
296 let mut out = String::new();
297 for (i, (k, v)) in tags.iter().enumerate() {
298 if i > 0 {
299 out.push('&');
300 }
301 url_encode_to(&mut out, k);
302 out.push('=');
303 url_encode_to(&mut out, v);
304 }
305 out
306}
307
308fn url_decode(s: &str) -> Result<String, String> {
314 let bytes = s.as_bytes();
315 let mut out = Vec::with_capacity(bytes.len());
316 let mut i = 0;
317 while i < bytes.len() {
318 match bytes[i] {
319 b'+' => {
320 out.push(b' ');
321 i += 1;
322 }
323 b'%' => {
324 if i + 2 >= bytes.len() {
325 return Err(format!("truncated %-escape at byte {i}"));
326 }
327 let hi = hex_digit(bytes[i + 1])
328 .ok_or_else(|| format!("non-hex byte after % at {}", i + 1))?;
329 let lo = hex_digit(bytes[i + 2])
330 .ok_or_else(|| format!("non-hex byte after % at {}", i + 2))?;
331 out.push((hi << 4) | lo);
332 i += 3;
333 }
334 b => {
335 out.push(b);
336 i += 1;
337 }
338 }
339 }
340 String::from_utf8(out).map_err(|e| format!("invalid UTF-8: {e}"))
341}
342
343fn hex_digit(b: u8) -> Option<u8> {
344 match b {
345 b'0'..=b'9' => Some(b - b'0'),
346 b'a'..=b'f' => Some(10 + b - b'a'),
347 b'A'..=b'F' => Some(10 + b - b'A'),
348 _ => None,
349 }
350}
351
352fn url_encode_to(out: &mut String, s: &str) {
357 for &b in s.as_bytes() {
358 let unreserved =
359 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~';
360 if unreserved {
361 out.push(b as char);
362 } else {
363 out.push('%');
364 out.push(HEX[((b >> 4) & 0x0F) as usize] as char);
365 out.push(HEX[(b & 0x0F) as usize] as char);
366 }
367 }
368}
369
370const HEX: &[u8; 16] = b"0123456789ABCDEF";
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn from_pairs_too_many_rejected() {
378 let pairs: Vec<(String, String)> = (0..11)
382 .map(|i| (format!("k{i}"), format!("v{i}")))
383 .collect();
384 let err = TagSet::from_pairs(pairs).expect_err("must reject 11 pairs");
385 assert!(
386 matches!(
387 err,
388 TagError::TooManyTags {
389 got: 11,
390 max: MAX_TAGS_PER_OBJECT
391 }
392 ),
393 "got: {err:?}"
394 );
395 }
396
397 #[test]
398 fn from_pairs_long_key_rejected() {
399 let pairs = vec![("k".repeat(129), "v".into())];
400 let err = TagSet::from_pairs(pairs).expect_err("must reject 129-byte key");
401 assert!(
402 matches!(
403 err,
404 TagError::KeyTooLong {
405 len: 129,
406 max: MAX_TAG_KEY_BYTES
407 }
408 ),
409 "got: {err:?}"
410 );
411 }
412
413 #[test]
414 fn from_pairs_long_value_rejected() {
415 let pairs = vec![("k".into(), "v".repeat(257))];
416 let err = TagSet::from_pairs(pairs).expect_err("must reject 257-byte value");
417 assert!(
418 matches!(
419 err,
420 TagError::ValueTooLong { ref key, len: 257, max: MAX_TAG_VALUE_BYTES }
421 if key == "k"
422 ),
423 "got: {err:?}"
424 );
425 }
426
427 #[test]
428 fn from_pairs_at_limits_accepted() {
429 let pairs: Vec<(String, String)> = (0..10)
432 .map(|i| {
433 let k = format!("k{i}");
434 let v = format!("v{i}");
435 let k = format!("{k:k<128}");
436 let v = format!("{v:v<256}");
437 (k, v)
438 })
439 .collect();
440 for (k, v) in &pairs {
442 assert_eq!(k.len(), 128);
443 assert_eq!(v.len(), 256);
444 }
445 let s = TagSet::from_pairs(pairs).expect("at-limit pairs must pass");
446 assert_eq!(s.len(), 10);
447 }
448
449 #[test]
450 fn parse_tagging_header_basic() {
451 let s = parse_tagging_header("K1=V1&K2=V2").expect("parse");
452 assert_eq!(s.len(), 2);
453 assert_eq!(s.get("K1"), Some("V1"));
454 assert_eq!(s.get("K2"), Some("V2"));
455 }
456
457 #[test]
458 fn parse_tagging_header_url_encoded_values() {
459 let s = parse_tagging_header("Path=foo%2Fbar&Greet=hello%20world&Plus=a+b").expect("parse");
461 assert_eq!(s.get("Path"), Some("foo/bar"));
462 assert_eq!(s.get("Greet"), Some("hello world"));
463 assert_eq!(s.get("Plus"), Some("a b"));
464 }
465
466 #[test]
467 fn parse_tagging_header_empty_value() {
468 let s = parse_tagging_header("Bare").expect("parse");
469 assert_eq!(s.get("Bare"), Some(""));
470 let s2 = parse_tagging_header("K=").expect("parse");
471 assert_eq!(s2.get("K"), Some(""));
472 }
473
474 #[test]
475 fn parse_tagging_header_empty_returns_empty_set() {
476 let s = parse_tagging_header("").expect("parse");
477 assert!(s.is_empty());
478 let s2 = parse_tagging_header(" ").expect("parse");
479 assert!(s2.is_empty());
480 }
481
482 #[test]
483 fn parse_tagging_header_truncated_escape_rejected() {
484 let err = parse_tagging_header("K=%2").expect_err("truncated");
485 assert!(matches!(err, TagError::InvalidHeader(_)));
486 }
487
488 #[test]
489 fn render_tagging_header_round_trip() {
490 let original = TagSet::from_pairs(vec![
491 ("Project".into(), "Phoenix".into()),
492 ("Env".into(), "prod with space".into()),
493 ("Path".into(), "data/2026".into()),
494 ])
495 .expect("ts");
496 let rendered = render_tagging_header(&original);
497 let parsed = parse_tagging_header(&rendered).expect("parse");
498 assert_eq!(parsed, original);
499 }
500
501 #[test]
502 fn manager_object_put_get_delete() {
503 let m = TagManager::new();
504 let tags = TagSet::from_pairs(vec![("Owner".into(), "alice".into())]).expect("ts");
505 m.put_object_tags("b", "k", tags.clone());
506 assert_eq!(m.get_object_tags("b", "k"), Some(tags));
507 m.delete_object_tags("b", "k");
508 assert!(m.get_object_tags("b", "k").is_none());
509 m.delete_object_tags("b", "k");
511 }
512
513 #[test]
514 fn manager_bucket_put_get_delete() {
515 let m = TagManager::new();
516 let tags = TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).expect("ts");
517 m.put_bucket_tags("b", tags.clone());
518 assert_eq!(m.get_bucket_tags("b"), Some(tags));
519 m.delete_bucket_tags("b");
520 assert!(m.get_bucket_tags("b").is_none());
521 }
522
523 #[test]
524 fn manager_object_and_bucket_independent() {
525 let m = TagManager::new();
529 m.put_object_tags(
530 "b",
531 "k",
532 TagSet::from_pairs(vec![("o".into(), "1".into())]).unwrap(),
533 );
534 m.put_bucket_tags(
535 "b",
536 TagSet::from_pairs(vec![("b".into(), "2".into())]).unwrap(),
537 );
538 assert_eq!(m.get_object_tags("b", "k").unwrap().get("o"), Some("1"));
539 assert!(m.get_object_tags("b", "k").unwrap().get("b").is_none());
540 assert_eq!(m.get_bucket_tags("b").unwrap().get("b"), Some("2"));
541 assert!(m.get_bucket_tags("b").unwrap().get("o").is_none());
542 }
543
544 #[test]
545 fn manager_json_snapshot_round_trip() {
546 let m = TagManager::new();
547 m.put_object_tags(
548 "b1",
549 "k1",
550 TagSet::from_pairs(vec![("Project".into(), "Phoenix".into())]).unwrap(),
551 );
552 m.put_object_tags(
553 "b2",
554 "k2",
555 TagSet::from_pairs(vec![("Env".into(), "prod".into())]).unwrap(),
556 );
557 m.put_bucket_tags(
558 "b1",
559 TagSet::from_pairs(vec![("CostCenter".into(), "42".into())]).unwrap(),
560 );
561 let json = m.to_json().expect("to_json");
562 let m2 = TagManager::from_json(&json).expect("from_json");
563 assert_eq!(
564 m2.get_object_tags("b1", "k1").unwrap().get("Project"),
565 Some("Phoenix")
566 );
567 assert_eq!(
568 m2.get_object_tags("b2", "k2").unwrap().get("Env"),
569 Some("prod")
570 );
571 assert_eq!(
572 m2.get_bucket_tags("b1").unwrap().get("CostCenter"),
573 Some("42")
574 );
575 }
576
577 #[test]
578 fn tag_set_get_last_wins_on_duplicate_keys() {
579 let s = TagSet(vec![("K".into(), "A".into()), ("K".into(), "B".into())]);
586 assert_eq!(s.get("K"), Some("B"));
587 }
588
589 #[test]
592 fn parse_tagging_header_empty_key_rejected() {
593 let err = parse_tagging_header("=value").expect_err("empty key");
597 assert!(matches!(err, TagError::EmptyKey), "got: {err:?}");
598 }
599
600 #[test]
601 fn parse_tagging_header_long_key_rejected() {
602 let header = format!("{}=v", "k".repeat(129));
604 let err = parse_tagging_header(&header).expect_err("129-byte key");
605 assert!(
606 matches!(
607 err,
608 TagError::KeyTooLong {
609 len: 129,
610 max: MAX_TAG_KEY_BYTES
611 }
612 ),
613 "got: {err:?}"
614 );
615 }
616
617 #[test]
618 fn parse_tagging_header_long_value_rejected() {
619 let header = format!("k={}", "v".repeat(257));
622 let err = parse_tagging_header(&header).expect_err("257-byte value");
623 assert!(
624 matches!(
625 err,
626 TagError::ValueTooLong { ref key, len: 257, max: MAX_TAG_VALUE_BYTES }
627 if key == "k"
628 ),
629 "got: {err:?}"
630 );
631 }
632
633 #[test]
634 fn parse_tagging_header_duplicate_key_rejected() {
635 let err = parse_tagging_header("K=A&K=B").expect_err("dup key");
638 assert!(
639 matches!(err, TagError::DuplicateKey { ref key } if key == "K"),
640 "got: {err:?}"
641 );
642 }
643
644 #[test]
645 fn parse_tagging_header_too_many_tags_rejected() {
646 let header: String = (0..11)
648 .map(|i| format!("k{i}=v{i}"))
649 .collect::<Vec<_>>()
650 .join("&");
651 let err = parse_tagging_header(&header).expect_err("11 tags");
652 assert!(
653 matches!(
654 err,
655 TagError::TooManyTags {
656 got: 11,
657 max: MAX_TAGS_PER_OBJECT
658 }
659 ),
660 "got: {err:?}"
661 );
662 }
663
664 #[test]
669 fn tagging_to_json_after_panic_recovers_via_poison() {
670 let m = TagManager::new();
671 m.put_object_tags(
672 "b",
673 "k",
674 TagSet::from_pairs(vec![("Project".into(), "Phoenix".into())]).expect("valid"),
675 );
676 let m = std::sync::Arc::new(m);
677 let m_cl = std::sync::Arc::clone(&m);
678 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
679 let mut g = m_cl.objects.write().expect("clean lock");
680 g.entry(("b".into(), "k2".into())).or_default();
681 panic!("force-poison");
682 }));
683 assert!(
684 m.objects.is_poisoned(),
685 "write panic must poison objects lock"
686 );
687 let json = m.to_json().expect("to_json after poison must succeed");
688 let m2 = TagManager::from_json(&json).expect("from_json");
689 assert!(m2.get_object_tags("b", "k").is_some());
690 }
691}