1use std::sync::atomic::Ordering;
50
51use anyhow::{bail, Result};
52
53use crate::db::Db;
54
55pub const REFS: &str = "_nedb.refs";
57
58pub const TAGS: &str = "_nedb.tags";
61
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66pub struct TagRecord {
67 pub name: String,
68 pub at_seq: u64,
70 pub state_root: Option<String>,
74 pub created_seq: u64,
77 pub deleted: bool,
79 pub message: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct RefRecord {
85 pub name: String,
86 pub at_seq: u64,
87 pub updated_seq: u64,
89}
90
91#[derive(serde::Serialize, serde::Deserialize)]
95struct StoredRef {
96 name: String,
97 at_seq: u64,
98 updated_seq: u64,
99 #[serde(default)]
100 deleted: bool,
101}
102
103pub fn validate_ref_name(name: &str) -> Result<()> {
115 if name.is_empty() {
116 bail!("ref/tag name is empty");
117 }
118 if name.len() > 255 {
119 bail!("ref/tag name is {} bytes; the limit is 255", name.len());
120 }
121 if name.contains('/') || name.contains('\\') {
122 bail!(
123 "ref/tag name {:?} contains a path separator — the name is an id in a \
124 reserved collection, and a separator makes it look like a namespace \
125 that does not exist",
126 name
127 );
128 }
129 if name.contains('\0') {
130 bail!("ref/tag name {:?} contains a NUL byte", name);
131 }
132 if name != name.trim() {
133 bail!(
134 "ref/tag name {:?} has leading or trailing whitespace — refused rather \
135 than trimmed, because the name you get back must be the name you gave",
136 name
137 );
138 }
139 if name.bytes().all(|b| b.is_ascii_digit()) {
147 bail!(
148 "ref/tag name {:?} is purely numeric — it would be ambiguous with a \
149 sequence number wherever a revision is accepted, and an argument \
150 resolver must not guess which was meant",
151 name
152 );
153 }
154 Ok(())
155}
156
157fn read_tag_raw(db: &Db, name: &str) -> Option<TagRecord> {
165 let n = db.get(TAGS, name)?;
166 serde_json::from_value(n.data).ok()
167}
168
169fn write_tag(db: &Db, rec: &TagRecord) -> Result<()> {
170 db.put_unchecked(TAGS, &rec.name, serde_json::to_value(rec)?, vec![], None, None)?;
171 Ok(())
172}
173
174pub fn create_tag(db: &Db, name: &str, at_seq: u64, message: Option<&str>) -> Result<TagRecord> {
197 validate_ref_name(name)?;
198
199 let next_seq = db.seq.load(Ordering::SeqCst);
201 if at_seq >= next_seq {
202 bail!(
203 "cannot tag {:?} at sequence {}: the database is at sequence {} — a tag \
204 pointing into the future names no state, and would start naming \
205 whatever is written there later",
206 name,
207 at_seq,
208 next_seq.saturating_sub(1)
209 );
210 }
211
212 if let Some(existing) = read_tag_raw(db, name) {
213 if existing.deleted {
214 bail!(
215 "tag {:?} was previously deleted (it pointed at sequence {}) and its \
216 name cannot be reused: a name that could be recreated at a different \
217 target makes every past reference to it ambiguous — which {:?} did \
218 that build use? Pick a new name.",
219 name,
220 existing.at_seq,
221 name
222 );
223 }
224 if existing.at_seq != at_seq {
225 bail!(
226 "tag {:?} already points at sequence {} and a tag target is \
227 immutable; refusing to move it to {}. Use a ref if you want a \
228 pointer that moves.",
229 name,
230 existing.at_seq,
231 at_seq
232 );
233 }
234 return Ok(existing);
236 }
237
238 let state_root = db.get_root(at_seq).map(|r| r.root.state_root);
239 let rec = TagRecord {
240 name: name.to_string(),
241 at_seq,
242 state_root,
243 created_seq: next_seq,
244 deleted: false,
245 message: message.map(|s| s.to_string()),
246 };
247 write_tag(db, &rec)?;
248 Ok(rec)
249}
250
251pub fn get_tag(db: &Db, name: &str) -> Option<TagRecord> {
255 read_tag_raw(db, name).filter(|t| !t.deleted)
256}
257
258pub fn get_tag_including_deleted(db: &Db, name: &str) -> Option<TagRecord> {
261 read_tag_raw(db, name)
262}
263
264fn all_tags(db: &Db) -> Vec<TagRecord> {
265 let mut out: Vec<TagRecord> = db
266 .id_index
267 .list_ids(TAGS)
268 .into_iter()
269 .filter_map(|id| read_tag_raw(db, &id))
270 .collect();
271 out.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
272 out
273}
274
275pub fn list_tags(db: &Db) -> Vec<TagRecord> {
278 all_tags(db).into_iter().filter(|t| !t.deleted).collect()
279}
280
281pub fn list_tags_including_deleted(db: &Db) -> Vec<TagRecord> {
283 all_tags(db)
284}
285
286pub fn delete_tag(db: &Db, name: &str) -> Result<bool> {
294 validate_ref_name(name)?;
295 let existing = match read_tag_raw(db, name) {
296 None => return Ok(false),
297 Some(t) => t,
298 };
299 if existing.deleted {
300 return Ok(false);
301 }
302 let rec = TagRecord { deleted: true, ..existing };
305 write_tag(db, &rec)?;
306 Ok(true)
307}
308
309fn read_ref_raw(db: &Db, name: &str) -> Option<StoredRef> {
312 let n = db.get(REFS, name)?;
313 serde_json::from_value(n.data).ok()
314}
315
316pub fn set_ref(db: &Db, name: &str, at_seq: u64) -> Result<RefRecord> {
326 validate_ref_name(name)?;
327 let next_seq = db.seq.load(Ordering::SeqCst);
328 if at_seq >= next_seq {
329 bail!(
330 "cannot point ref {:?} at sequence {}: the database is at sequence {} — \
331 that state does not exist yet",
332 name,
333 at_seq,
334 next_seq.saturating_sub(1)
335 );
336 }
337 let rec = RefRecord { name: name.to_string(), at_seq, updated_seq: next_seq };
338 let stored = StoredRef {
339 name: rec.name.clone(),
340 at_seq: rec.at_seq,
341 updated_seq: rec.updated_seq,
342 deleted: false,
343 };
344 db.put_unchecked(REFS, name, serde_json::to_value(&stored)?, vec![], None, None)?;
345 Ok(rec)
346}
347
348pub fn get_ref(db: &Db, name: &str) -> Option<RefRecord> {
350 let s = read_ref_raw(db, name)?;
351 if s.deleted {
352 return None;
353 }
354 Some(RefRecord { name: s.name, at_seq: s.at_seq, updated_seq: s.updated_seq })
355}
356
357pub fn list_refs(db: &Db) -> Vec<RefRecord> {
359 let mut out: Vec<RefRecord> = db
360 .id_index
361 .list_ids(REFS)
362 .into_iter()
363 .filter_map(|id| get_ref(db, &id))
364 .collect();
365 out.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
366 out
367}
368
369pub fn delete_ref(db: &Db, name: &str) -> Result<bool> {
381 validate_ref_name(name)?;
382 let existing = match read_ref_raw(db, name) {
383 None => return Ok(false),
384 Some(s) => s,
385 };
386 if existing.deleted {
387 return Ok(false);
388 }
389 let stored = StoredRef { deleted: true, ..existing };
390 db.put_unchecked(REFS, name, serde_json::to_value(&stored)?, vec![], None, None)?;
391 Ok(true)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use serde_json::json;
398 use tempfile::{tempdir, TempDir};
399
400 fn db_with(n: u64) -> (TempDir, Db) {
403 let dir = tempdir().unwrap();
404 let db = Db::open(dir.path(), None).unwrap();
405 for i in 0..n {
406 db.put("orders", &format!("o{}", i), json!({ "i": i }), vec![], None, None)
407 .unwrap();
408 }
409 (dir, db)
410 }
411
412 fn tip(db: &Db) -> u64 {
413 db.seq.load(Ordering::SeqCst).saturating_sub(1)
414 }
415
416 #[test]
417 fn a_tag_is_created_read_back_and_listed() {
418 let (_d, db) = db_with(3);
419 let t = create_tag(&db, "v1.0", 1, Some("first cut")).unwrap();
420 assert_eq!(t.at_seq, 1);
421 assert!(!t.deleted);
422 assert_eq!(t.message.as_deref(), Some("first cut"));
423
424 let got = get_tag(&db, "v1.0").expect("tag readable by name");
425 assert_eq!(got, t);
426
427 let listed = list_tags(&db);
428 assert_eq!(listed.len(), 1);
429 assert_eq!(listed[0].name, "v1.0");
430 }
431
432 #[test]
433 fn retagging_the_same_name_at_a_different_seq_is_refused_and_names_the_target() {
434 let (_d, db) = db_with(5);
435 create_tag(&db, "v1.0", 1, None).unwrap();
436 let err = create_tag(&db, "v1.0", 3, None).unwrap_err().to_string();
437 assert!(err.contains("already points at sequence 1"), "message was: {}", err);
438 assert!(err.contains("immutable"), "message was: {}", err);
439 assert_eq!(get_tag(&db, "v1.0").unwrap().at_seq, 1);
441 }
442
443 #[test]
444 fn retagging_the_same_name_at_the_same_seq_is_idempotent_success() {
445 let (_d, db) = db_with(5);
446 let first = create_tag(&db, "v1.0", 2, Some("m")).unwrap();
447 let again = create_tag(&db, "v1.0", 2, Some("different message")).unwrap();
448 assert_eq!(first, again);
451 assert_eq!(list_tags(&db).len(), 1);
452 }
453
454 #[test]
455 fn deleting_a_tag_leaves_an_audited_tombstone_with_its_target_intact() {
456 let (_d, db) = db_with(5);
457 create_tag(&db, "v1.0", 2, Some("oops")).unwrap();
458 assert!(delete_tag(&db, "v1.0").unwrap());
459
460 assert!(get_tag(&db, "v1.0").is_none(), "a deleted tag is not live");
461 assert!(list_tags(&db).is_empty());
462
463 let audit = list_tags_including_deleted(&db);
464 assert_eq!(audit.len(), 1, "the tombstone does not vanish");
465 assert!(audit[0].deleted);
466 assert_eq!(audit[0].at_seq, 2, "the tombstone remembers what it pointed at");
467 assert_eq!(audit[0].message.as_deref(), Some("oops"));
468 assert_eq!(get_tag_including_deleted(&db, "v1.0").unwrap().at_seq, 2);
469
470 assert!(!delete_tag(&db, "v1.0").unwrap());
472 assert_eq!(list_tags_including_deleted(&db).len(), 1);
473 }
474
475 #[test]
477 fn a_deleted_tag_name_cannot_be_reused_at_any_target() {
478 let (_d, db) = db_with(5);
479 create_tag(&db, "v1.0", 2, None).unwrap();
480 delete_tag(&db, "v1.0").unwrap();
481
482 let err = create_tag(&db, "v1.0", 4, None).unwrap_err().to_string();
484 assert!(err.contains("previously deleted"), "message was: {}", err);
485 assert!(err.contains("sequence 2"), "message must say what it pointed at: {}", err);
486
487 let err = create_tag(&db, "v1.0", 2, None).unwrap_err().to_string();
490 assert!(err.contains("previously deleted"), "message was: {}", err);
491
492 assert!(get_tag(&db, "v1.0").is_none());
493 assert_eq!(list_tags(&db).len(), 0);
494 create_tag(&db, "v1.0.1", 2, None).unwrap();
496 }
497
498 #[test]
499 fn the_no_reuse_ban_survives_a_reopen() {
500 let dir = tempdir().unwrap();
501 {
502 let db = Db::open(dir.path(), None).unwrap();
503 db.put("orders", "a", json!({}), vec![], None, None).unwrap();
504 db.put("orders", "b", json!({}), vec![], None, None).unwrap();
505 create_tag(&db, "v1.0", 1, None).unwrap();
506 delete_tag(&db, "v1.0").unwrap();
507 db.flush_all();
508 }
509 let db = Db::open(dir.path(), None).unwrap();
510 let err = create_tag(&db, "v1.0", 1, None).unwrap_err().to_string();
511 assert!(err.contains("previously deleted"), "message was: {}", err);
512 }
513
514 #[test]
515 fn a_tag_captures_a_persisted_state_root_and_reports_none_when_there_is_not_one() {
516 let (_d, db) = db_with(4);
517 let at = tip(&db);
518 let untagged = create_tag(&db, "no-root", at, None).unwrap();
519 assert!(
520 untagged.state_root.is_none(),
521 "no root was persisted at {}, and the tag must say so rather than \
522 computing one behind the operator's back",
523 at
524 );
525
526 let persisted = db.create_root_at(at).unwrap();
527 let tagged = create_tag(&db, "has-root", at, None).unwrap();
528 assert_eq!(tagged.state_root.as_deref(), Some(persisted.root.state_root.as_str()));
529 }
530
531 #[test]
532 fn tagging_a_future_sequence_is_refused() {
533 let (_d, db) = db_with(3);
534 let next = db.seq.load(Ordering::SeqCst);
535 for future in [next, next + 1, u64::MAX] {
536 let err = create_tag(&db, "ahead", future, None).unwrap_err().to_string();
537 assert!(err.contains("future"), "message was: {}", err);
538 }
539 create_tag(&db, "here", tip(&db), None).unwrap();
541 }
542
543 #[test]
544 fn invalid_names_are_refused_one_category_at_a_time() {
545 let (_d, db) = db_with(2);
546 let cases: &[(&str, &str)] = &[
547 ("", "empty"),
548 (" v1", "whitespace"),
549 ("v1 ", "whitespace"),
550 ("a/b", "path separator"),
551 ("a\\b", "path separator"),
552 ("a\0b", "NUL"),
553 ("42", "numeric"),
554 ("0", "numeric"),
555 ];
556 for (name, why) in cases {
557 let e = validate_ref_name(name)
558 .unwrap_err()
559 .to_string();
560 assert!(
561 e.to_lowercase().contains(&why.to_lowercase()),
562 "{:?} should be refused for {:?}, message was: {}", name, why, e
563 );
564 assert!(create_tag(&db, name, 0, None).is_err(), "create_tag({:?})", name);
568 assert!(set_ref(&db, name, 0).is_err(), "set_ref({:?})", name);
569 assert!(delete_tag(&db, name).is_err(), "delete_tag({:?})", name);
570 assert!(delete_ref(&db, name).is_err(), "delete_ref({:?})", name);
571 }
572
573 let too_long = "v".repeat(256);
574 assert!(validate_ref_name(&too_long).unwrap_err().to_string().contains("256 bytes"));
575
576 for ok in ["v1.0", "main", "release-2026", "v42", "42a", "a b"] {
578 validate_ref_name(ok).unwrap_or_else(|e| panic!("{:?} refused: {}", ok, e));
579 }
580 }
581
582 #[test]
583 fn a_ref_is_set_moved_and_read_back() {
584 let (_d, db) = db_with(5);
585 let r = set_ref(&db, "main", 1).unwrap();
586 assert_eq!(r.at_seq, 1);
587 assert_eq!(get_ref(&db, "main").unwrap().at_seq, 1);
588
589 set_ref(&db, "main", 4).unwrap();
591 assert_eq!(get_ref(&db, "main").unwrap().at_seq, 4);
592
593 let listed = list_refs(&db);
594 assert_eq!(listed.len(), 1);
595 assert_eq!(listed[0].at_seq, 4);
596 assert!(get_ref(&db, "nope").is_none());
597 }
598
599 #[test]
602 fn a_deleted_ref_name_is_reusable_but_a_deleted_tag_name_is_not() {
603 let (_d, db) = db_with(5);
604
605 set_ref(&db, "release", 1).unwrap();
606 assert!(delete_ref(&db, "release").unwrap());
607 assert!(get_ref(&db, "release").is_none());
608 assert!(list_refs(&db).is_empty());
609 assert!(!delete_ref(&db, "release").unwrap(), "already gone");
610 set_ref(&db, "release", 3).unwrap();
612 assert_eq!(get_ref(&db, "release").unwrap().at_seq, 3);
613
614 create_tag(&db, "release", 1, None).unwrap();
615 delete_tag(&db, "release").unwrap();
616 assert!(
617 create_tag(&db, "release", 3, None).is_err(),
618 "the same sequence of operations that is legal for a ref must be \
619 refused for a tag"
620 );
621 }
622
623 #[test]
624 fn tags_and_refs_survive_a_reopen() {
625 let dir = tempdir().unwrap();
626 let root_hex;
627 {
628 let db = Db::open(dir.path(), None).unwrap();
629 for i in 0..4 {
630 db.put("orders", &format!("o{}", i), json!({ "i": i }), vec![], None, None)
631 .unwrap();
632 }
633 let at = db.seq.load(Ordering::SeqCst) - 1;
634 root_hex = db.create_root_at(at).unwrap().root.state_root;
635 create_tag(&db, "v1.0", at, Some("ship it")).unwrap();
636 create_tag(&db, "v0.9", 1, None).unwrap();
637 delete_tag(&db, "v0.9").unwrap();
638 set_ref(&db, "main", 2).unwrap();
639 db.flush_all();
640 }
641
642 let db = Db::open(dir.path(), None).unwrap();
643 let t = get_tag(&db, "v1.0").expect("tag survived reopen");
644 assert_eq!(t.message.as_deref(), Some("ship it"));
645 assert_eq!(t.state_root.as_deref(), Some(root_hex.as_str()));
646 assert_eq!(list_tags(&db).len(), 1);
647 assert_eq!(list_tags_including_deleted(&db).len(), 2);
648 assert!(list_tags_including_deleted(&db).iter().any(|x| x.name == "v0.9" && x.deleted));
649 assert_eq!(get_ref(&db, "main").unwrap().at_seq, 2);
650 assert_eq!(list_refs(&db).len(), 1);
651 }
652
653 #[test]
657 fn naming_a_state_does_not_change_it() {
658 let (_d, db) = db_with(4);
659 let before = db.state_root().unwrap();
660
661 create_tag(&db, "v1.0", 1, Some("a message long enough to matter")).unwrap();
662 set_ref(&db, "main", 2).unwrap();
663 delete_tag(&db, "v1.0").unwrap();
664 delete_ref(&db, "main").unwrap();
665
666 assert_eq!(db.state_root().unwrap(), before);
667 assert!(
668 !db.collections().iter().any(|c| c == TAGS || c == REFS),
669 "reserved collections must never enter the namespace"
670 );
671 }
672}