1use std::collections::{HashMap, HashSet};
4use std::fs::{File, OpenOptions};
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::format::{
11 derive_import_id, derive_stream_id, BatchId, BranchId, CodecId, EventId, EventType, Metadata,
12 RecordEnvelopeV2, StreamRevision,
13};
14use crate::log::{Log, MIGRATION_IN_PROGRESS};
15use crate::{BranchInfo, BranchName, BranchStatus, OwnedStoredRecord, Result, SalamanderError};
16
17const COMPLETE_FILE: &str = "migration.complete.json";
18const LEGACY_HEADER_LEN: usize = 16;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct MigrationReport {
23 pub source_records: u64,
25 pub previously_imported: u64,
27 pub newly_imported: u64,
29 pub destination_head: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct BranchMigrationReport {
37 pub source_records: u64,
39 pub migrated_records: u64,
41 pub removed_marker_records: u64,
43 pub branches_created: u64,
45 pub destination_head: u64,
47}
48
49#[derive(Debug)]
50struct LegacyFork {
51 child_stream: String,
52 parent_stream: String,
53 source_fork_position: u64,
54 marker_position: u64,
55 created_at_unix_nanos: i64,
56 id: BranchId,
57}
58
59pub fn migrate_legacy_branches(
62 source: impl AsRef<Path>,
63 destination: impl AsRef<Path>,
64) -> Result<BranchMigrationReport> {
65 let source = source.as_ref();
66 let destination = destination.as_ref();
67 if source == destination {
68 return Err(SalamanderError::Migration(
69 "branch migration requires a different destination directory".into(),
70 ));
71 }
72 if destination.exists() {
73 return Err(SalamanderError::Migration(
74 "branch migration destination must not already exist".into(),
75 ));
76 }
77
78 let source_log = Log::open(source)?;
79 if source_log.system_records().next().is_some() {
80 return Err(SalamanderError::Migration(
81 "source already contains engine system metadata".into(),
82 ));
83 }
84 let source_database_id = source_log.database_id();
85 let records = source_log.records_from(0).collect::<Result<Vec<_>>>()?;
86 let forks = discover_legacy_forks(&records, source_database_id)?;
87 validate_legacy_forks(&forks, &records)?;
88 let marker_positions: HashSet<_> = forks.iter().map(|fork| fork.marker_position).collect();
89
90 std::fs::create_dir_all(destination)?;
91 write_json_atomic(
92 &destination.join(MIGRATION_IN_PROGRESS),
93 &serde_json::json!({ "version": 1, "kind": "legacy-branches", "source": source.display().to_string() }),
94 )?;
95 let mut destination_log = Log::open_for_migration(destination)?;
96 let destination_database_id = destination_log.database_id();
97
98 let mut branch_by_stream = HashMap::new();
99 let mut canonical_stream_by_legacy_stream = HashMap::new();
100 for fork in &forks {
101 let parent = branch_by_stream
102 .get(&fork.parent_stream)
103 .copied()
104 .unwrap_or(BranchId::ZERO);
105 let fork_position = destination_position(fork.source_fork_position, &marker_positions);
106 let canonical_stream = canonical_stream_by_legacy_stream
107 .get(&fork.parent_stream)
108 .cloned()
109 .unwrap_or_else(|| fork.parent_stream.clone());
110 let mut metadata = Metadata::new();
111 metadata.insert(
112 "salamander.legacy_stream".into(),
113 fork.child_stream.as_bytes().to_vec(),
114 );
115 metadata.insert(
116 "session_stream".into(),
117 canonical_stream.as_bytes().to_vec(),
118 );
119 let info = BranchInfo {
120 id: fork.id,
121 name: BranchName::new(fork.child_stream.clone())?,
122 parent: Some(parent),
123 fork_position: Some(fork_position),
124 created_at_unix_nanos: fork.created_at_unix_nanos,
125 metadata,
126 status: BranchStatus::Active,
127 };
128 append_branch_creation(&mut destination_log, destination_database_id, &info)?;
129 branch_by_stream.insert(fork.child_stream.clone(), fork.id);
130 canonical_stream_by_legacy_stream.insert(fork.child_stream.clone(), canonical_stream);
131 }
132
133 let mut revisions: HashMap<(BranchId, String), u64> = HashMap::new();
134 for record in &records {
135 if marker_positions.contains(&record.position) {
136 continue;
137 }
138 let legacy_stream = record_stream(record)?;
139 let branch = branch_by_stream
140 .get(&legacy_stream)
141 .copied()
142 .unwrap_or(BranchId::ZERO);
143 let stream = canonical_stream_by_legacy_stream
144 .get(&legacy_stream)
145 .cloned()
146 .unwrap_or(legacy_stream);
147 let revision = revisions.entry((branch, stream.clone())).or_insert(0);
148 let mut envelope = record.envelope.clone();
149 envelope.database_id = destination_database_id;
150 envelope.branch_id = branch;
151 envelope.stream_id = derive_stream_id(destination_database_id, branch, &stream);
152 envelope.stream_revision = StreamRevision(*revision);
153 envelope
154 .metadata
155 .insert("salamander.stream_name".into(), stream.as_bytes().to_vec());
156 envelope.batch_id = BatchId::from_bytes(envelope.event_id.into_bytes());
157 envelope.batch_index = 0;
158 destination_log.append_batch(&[(envelope, record.payload.clone())])?;
159 *revision += 1;
160 }
161 destination_log.commit()?;
162 let destination_head = destination_log.head();
163 drop(destination_log);
164 drop(source_log);
165
166 let report = BranchMigrationReport {
167 source_records: records.len() as u64,
168 migrated_records: destination_head,
169 removed_marker_records: marker_positions.len() as u64,
170 branches_created: forks.len() as u64,
171 destination_head,
172 };
173 write_json_atomic(
174 &destination.join("branch-migration.complete.json"),
175 &serde_json::json!({
176 "version": 1,
177 "source_records": report.source_records,
178 "migrated_records": report.migrated_records,
179 "removed_marker_records": report.removed_marker_records,
180 "branches_created": report.branches_created,
181 }),
182 )?;
183 std::fs::remove_file(destination.join(MIGRATION_IN_PROGRESS))?;
184 sync_dir(destination)?;
185 Ok(report)
186}
187
188fn discover_legacy_forks(
189 records: &[OwnedStoredRecord],
190 source_database_id: crate::DatabaseId,
191) -> Result<Vec<LegacyFork>> {
192 let mut forks = Vec::new();
193 for record in records {
194 let stream = record_stream(record)?;
195 let marker =
196 legacy_agent_marker(&record.payload).or_else(|| legacy_json_marker(&record.payload));
197 if let Some((parent_stream, source_fork_position)) = marker {
198 forks.push(LegacyFork {
199 child_stream: stream,
200 parent_stream,
201 source_fork_position,
202 marker_position: record.position,
203 created_at_unix_nanos: record.envelope.timestamp_unix_nanos,
204 id: BranchId::from_bytes(derive_import_id(
205 source_database_id.into_bytes(),
206 record.position,
207 )),
208 });
209 }
210 }
211 forks.sort_by_key(|fork| fork.marker_position);
212 Ok(forks)
213}
214
215fn legacy_agent_marker(payload: &[u8]) -> Option<(String, u64)> {
216 let body: crate::agent::EventBody = bincode::deserialize(payload).ok()?;
217 let crate::agent::EventBody::SessionStarted { config_hash, .. } = body else {
218 return None;
219 };
220 let prefix = ["forked", "_from="].concat();
221 let rest = config_hash.strip_prefix(&prefix)?;
222 let (parent, position) = rest.rsplit_once('@')?;
223 Some((parent.to_string(), position.parse().ok()?))
224}
225
226fn legacy_json_marker(payload: &[u8]) -> Option<(String, u64)> {
227 let body = bincode::deserialize::<crate::Json>(payload)
228 .map(|json| json.0)
229 .or_else(|_| serde_json::from_slice::<serde_json::Value>(payload))
230 .ok()?;
231 let key = ["__salamander", "_fork__"].concat();
232 let marker = body.get(&key)?;
233 Some((
234 marker.get("parent")?.as_str()?.to_string(),
235 marker.get("at")?.as_u64()?,
236 ))
237}
238
239fn validate_legacy_forks(forks: &[LegacyFork], records: &[OwnedStoredRecord]) -> Result<()> {
240 let mut children = HashSet::new();
241 let mut known_branches = HashSet::new();
242 let all_children: HashSet<_> = forks
243 .iter()
244 .map(|fork| fork.child_stream.as_str())
245 .collect();
246 for fork in forks {
247 if !children.insert(fork.child_stream.clone()) {
248 return Err(SalamanderError::Migration(format!(
249 "multiple legacy fork markers target stream {}",
250 fork.child_stream
251 )));
252 }
253 if fork.source_fork_position > records.len() as u64
254 || fork.source_fork_position > fork.marker_position
255 {
256 return Err(SalamanderError::Migration(format!(
257 "invalid fork position {} for marker at {}",
258 fork.source_fork_position, fork.marker_position
259 )));
260 }
261 if all_children.contains(fork.parent_stream.as_str())
262 && !known_branches.contains(&fork.parent_stream)
263 {
264 return Err(SalamanderError::Migration(
265 "legacy branch ancestry is not topological".into(),
266 ));
267 }
268 if fork.source_fork_position > 0 && fork.source_fork_position < records.len() as u64 {
269 let left = &records[fork.source_fork_position as usize - 1];
270 let right = &records[fork.source_fork_position as usize];
271 if left.envelope.batch_id == right.envelope.batch_id {
272 return Err(SalamanderError::Migration(format!(
273 "legacy fork position {} splits a batch",
274 fork.source_fork_position
275 )));
276 }
277 }
278 known_branches.insert(fork.child_stream.clone());
279 }
280 Ok(())
281}
282
283fn destination_position(source_position: u64, markers: &HashSet<u64>) -> u64 {
284 source_position
285 - markers
286 .iter()
287 .filter(|position| **position < source_position)
288 .count() as u64
289}
290
291fn record_stream(record: &OwnedStoredRecord) -> Result<String> {
292 let bytes = record
293 .envelope
294 .metadata
295 .get("salamander.stream_name")
296 .or_else(|| record.envelope.metadata.get("salamander.v1_namespace"))
297 .ok_or_else(|| {
298 SalamanderError::Migration(format!("record {} has no stream name", record.position))
299 })?;
300 std::str::from_utf8(bytes).map(str::to_owned).map_err(|_| {
301 SalamanderError::Migration(format!(
302 "record {} stream name is not UTF-8",
303 record.position
304 ))
305 })
306}
307
308fn append_branch_creation(
309 log: &mut Log,
310 database_id: crate::DatabaseId,
311 info: &BranchInfo,
312) -> Result<()> {
313 let payload = serde_json::to_vec(info)
314 .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
315 let event_bytes = derive_import_id(info.id.into_bytes(), 0);
316 let envelope = RecordEnvelopeV2 {
317 event_id: EventId::from_bytes(event_bytes),
318 database_id,
319 branch_id: info.id,
320 stream_id: crate::StreamId::ZERO,
321 stream_revision: StreamRevision(0),
322 timestamp_unix_nanos: info.created_at_unix_nanos,
323 event_type: EventType::new("salamander.branch.created")?,
324 schema_version: 1,
325 codec: CodecId::JSON_UTF8,
326 batch_id: BatchId::from_bytes(event_bytes),
327 batch_index: 0,
328 metadata: Metadata::new(),
329 };
330 log.append_system(&envelope, &payload)
331}
332
333#[derive(Debug, Serialize, Deserialize)]
334struct MigrationMarker {
335 version: u32,
336 source_identity_hex: String,
337 source_path: String,
338}
339
340#[derive(Debug, Deserialize)]
341struct LegacyManifest {
342 active_segment_base: u64,
343 #[serde(default = "legacy_storage_version")]
344 storage_format_version: u32,
345}
346
347fn legacy_storage_version() -> u32 {
348 1
349}
350
351struct LegacyEvent {
352 offset: u64,
353 timestamp_ms: u64,
354 namespace: String,
355 body: Vec<u8>,
356}
357
358pub fn migrate_v1(
364 source: impl AsRef<Path>,
365 destination: impl AsRef<Path>,
366) -> Result<MigrationReport> {
367 let source = source.as_ref();
368 let destination = destination.as_ref();
369 if source == destination {
370 return Err(SalamanderError::Migration(
371 "source and destination must be different directories".into(),
372 ));
373 }
374 if source.join("LOCK").exists() {
375 return Err(SalamanderError::Migration(
376 "v0.1 source has a LOCK file; migration requires an offline source".into(),
377 ));
378 }
379
380 let manifest_bytes = std::fs::read(source.join("manifest.json"))?;
381 let legacy_manifest: LegacyManifest = serde_json::from_slice(&manifest_bytes)
382 .map_err(|error| SalamanderError::Migration(format!("invalid v0.1 manifest: {error}")))?;
383 if legacy_manifest.storage_format_version != 1 {
384 return Err(SalamanderError::Migration(format!(
385 "source storage format is {}, expected v0.1 generation 1",
386 legacy_manifest.storage_format_version
387 )));
388 }
389
390 let segment_paths = discover_legacy_segments(source)?;
391 let source_identity = fingerprint_source(&manifest_bytes, &segment_paths)?;
392 let events = read_legacy_events(&segment_paths, legacy_manifest.active_segment_base)?;
393
394 if let Some(report) = completed_report(destination, source_identity, &events)? {
395 return Ok(report);
396 }
397
398 prepare_destination(source, destination, source_identity)?;
399 let mut log = Log::open_for_migration(destination)?;
400 let previously_imported = log.head();
401 if previously_imported > events.len() as u64 {
402 return Err(SalamanderError::Migration(
403 "destination contains more records than the source".into(),
404 ));
405 }
406 verify_imported_prefix(&log, &events, source_identity)?;
407
408 let database_id = log.database_id();
409 for event in events.iter().skip(previously_imported as usize) {
410 let id = derive_import_id(source_identity, event.offset);
411 let mut metadata = Metadata::new();
412 metadata.insert(
413 "salamander.stream_name".into(),
414 event.namespace.as_bytes().to_vec(),
415 );
416 metadata.insert(
417 "salamander.v1_namespace".into(),
418 event.namespace.as_bytes().to_vec(),
419 );
420 metadata.insert(
421 "salamander.v1_offset".into(),
422 event.offset.to_le_bytes().to_vec(),
423 );
424 let envelope = RecordEnvelopeV2 {
425 event_id: EventId::from_bytes(id),
426 database_id,
427 branch_id: BranchId::ZERO,
428 stream_id: derive_stream_id(database_id, BranchId::ZERO, &event.namespace),
429 stream_revision: StreamRevision(event.offset),
430 timestamp_unix_nanos: (event.timestamp_ms as i64).saturating_mul(1_000_000),
431 event_type: EventType::new("salamander.v1-import")?,
432 schema_version: 1,
433 codec: CodecId::RUST_BINCODE_V1,
434 batch_id: BatchId::from_bytes(id),
435 batch_index: 0,
436 metadata,
437 };
438 let position = log.append_enveloped(&envelope, &event.body)?;
439 if position != event.offset {
440 return Err(SalamanderError::Migration(format!(
441 "destination position {position} differs from v0.1 offset {}",
442 event.offset
443 )));
444 }
445 }
446 log.commit()?;
447 verify_imported_prefix(&log, &events, source_identity)?;
448 let destination_head = log.head();
449 drop(log);
450
451 let complete = MigrationMarker {
452 version: 1,
453 source_identity_hex: hex(&source_identity),
454 source_path: source.display().to_string(),
455 };
456 write_json_atomic(&destination.join(COMPLETE_FILE), &complete)?;
457 std::fs::remove_file(destination.join(MIGRATION_IN_PROGRESS))?;
458 sync_dir(destination)?;
459
460 Ok(MigrationReport {
461 source_records: events.len() as u64,
462 previously_imported,
463 newly_imported: events.len() as u64 - previously_imported,
464 destination_head,
465 })
466}
467
468fn completed_report(
469 destination: &Path,
470 identity: [u8; 16],
471 events: &[LegacyEvent],
472) -> Result<Option<MigrationReport>> {
473 let complete_path = destination.join(COMPLETE_FILE);
474 if !complete_path.exists() {
475 return Ok(None);
476 }
477 let marker: MigrationMarker =
478 serde_json::from_slice(&std::fs::read(complete_path)?).map_err(|error| {
479 SalamanderError::Migration(format!("invalid completion marker: {error}"))
480 })?;
481 if marker.source_identity_hex != hex(&identity) {
482 return Err(SalamanderError::Migration(
483 "completed destination belongs to a different v0.1 source".into(),
484 ));
485 }
486 let log = Log::open(destination)?;
487 verify_imported_prefix(&log, events, identity)?;
488 let head = log.head();
489 Ok(Some(MigrationReport {
490 source_records: events.len() as u64,
491 previously_imported: head,
492 newly_imported: 0,
493 destination_head: head,
494 }))
495}
496
497fn prepare_destination(source: &Path, destination: &Path, identity: [u8; 16]) -> Result<()> {
498 std::fs::create_dir_all(destination)?;
499 let marker_path = destination.join(MIGRATION_IN_PROGRESS);
500 let expected_hex = hex(&identity);
501 if marker_path.exists() {
502 let marker: MigrationMarker = serde_json::from_slice(&std::fs::read(&marker_path)?)
503 .map_err(|error| {
504 SalamanderError::Migration(format!("invalid migration marker: {error}"))
505 })?;
506 if marker.source_identity_hex != expected_hex {
507 return Err(SalamanderError::Migration(
508 "destination belongs to a different v0.1 source".into(),
509 ));
510 }
511 return Ok(());
512 }
513 if destination.join("manifest.json").exists() || destination.join(COMPLETE_FILE).exists() {
514 return Err(SalamanderError::Migration(
515 "destination is already a database or completed migration".into(),
516 ));
517 }
518 let marker = MigrationMarker {
519 version: 1,
520 source_identity_hex: expected_hex,
521 source_path: source.display().to_string(),
522 };
523 write_json_atomic(&marker_path, &marker)
524}
525
526fn verify_imported_prefix(log: &Log, source: &[LegacyEvent], identity: [u8; 16]) -> Result<()> {
527 let mut seen = 0usize;
528 for item in log.records_from(0) {
529 let record = item?;
530 let expected = source.get(seen).ok_or_else(|| {
531 SalamanderError::Migration("destination prefix exceeds source".into())
532 })?;
533 if record.position != expected.offset
534 || record.envelope.event_id.into_bytes() != derive_import_id(identity, expected.offset)
535 || record.payload != expected.body
536 || record
537 .envelope
538 .metadata
539 .get("salamander.v1_offset")
540 .map(Vec::as_slice)
541 != Some(expected.offset.to_le_bytes().as_slice())
542 {
543 return Err(SalamanderError::Migration(format!(
544 "destination prefix differs at offset {}",
545 expected.offset
546 )));
547 }
548 seen += 1;
549 }
550 if seen != log.head() as usize {
551 return Err(SalamanderError::Migration(
552 "destination head does not match verified prefix".into(),
553 ));
554 }
555 Ok(())
556}
557
558fn discover_legacy_segments(source: &Path) -> Result<Vec<(u64, PathBuf)>> {
559 let mut paths = Vec::new();
560 for entry in std::fs::read_dir(source.join("log"))? {
561 let path = entry?.path();
562 if path.extension().and_then(|value| value.to_str()) != Some("seg") {
563 continue;
564 }
565 let base = path
566 .file_stem()
567 .and_then(|value| value.to_str())
568 .and_then(|value| value.parse::<u64>().ok())
569 .ok_or_else(|| {
570 SalamanderError::Migration(format!("invalid segment name: {}", path.display()))
571 })?;
572 paths.push((base, path));
573 }
574 paths.sort_by_key(|(base, _)| *base);
575 if paths.is_empty() {
576 return Err(SalamanderError::Migration(
577 "v0.1 source has no segments".into(),
578 ));
579 }
580 Ok(paths)
581}
582
583fn read_legacy_events(paths: &[(u64, PathBuf)], active_base: u64) -> Result<Vec<LegacyEvent>> {
584 let mut events = Vec::new();
585 let mut expected_offset = 0u64;
586 for (base, path) in paths {
587 if *base != expected_offset {
588 return Err(SalamanderError::Migration(format!(
589 "v0.1 segment {} begins at {base}, expected {expected_offset}",
590 path.display()
591 )));
592 }
593 let bytes = std::fs::read(path)?;
594 let mut cursor = 0usize;
595 while cursor < bytes.len() {
596 match decode_legacy_record(&bytes[cursor..])? {
597 Some((offset, payload, consumed)) => {
598 if offset != expected_offset {
599 return Err(SalamanderError::Migration(format!(
600 "v0.1 offset {offset} is not expected {expected_offset}"
601 )));
602 }
603 events.push(split_legacy_event(offset, payload)?);
604 expected_offset += 1;
605 cursor += consumed;
606 }
607 None if *base == active_base => break,
608 None => {
609 return Err(SalamanderError::Migration(format!(
610 "closed v0.1 segment {} has a torn tail",
611 path.display()
612 )))
613 }
614 }
615 }
616 }
617 Ok(events)
618}
619
620fn decode_legacy_record(buf: &[u8]) -> Result<Option<(u64, &[u8], usize)>> {
621 if buf.len() < LEGACY_HEADER_LEN {
622 return Ok(None);
623 }
624 let payload_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
625 let stored_crc = u32::from_le_bytes(buf[4..8].try_into().unwrap());
626 let offset_bytes = &buf[8..16];
627 let offset = u64::from_le_bytes(offset_bytes.try_into().unwrap());
628 let total = LEGACY_HEADER_LEN
629 .checked_add(payload_len)
630 .ok_or_else(|| SalamanderError::Migration("v0.1 record length overflow".into()))?;
631 if buf.len() < total {
632 return Ok(None);
633 }
634 let payload = &buf[LEGACY_HEADER_LEN..total];
635 let computed = crc32c::crc32c_append(crc32c::crc32c(offset_bytes), payload);
636 if computed != stored_crc {
637 return Err(SalamanderError::Corrupt {
638 offset,
639 reason: "v0.1 CRC mismatch during migration".into(),
640 });
641 }
642 Ok(Some((offset, payload, total)))
643}
644
645fn split_legacy_event(offset: u64, payload: &[u8]) -> Result<LegacyEvent> {
646 if payload.len() < 24 {
647 return Err(SalamanderError::Migration(format!(
648 "v0.1 event at {offset} is shorter than its fixed envelope"
649 )));
650 }
651 let timestamp_ms = u64::from_le_bytes(payload[8..16].try_into().unwrap());
652 let namespace_len = u64::from_le_bytes(payload[16..24].try_into().unwrap());
653 let namespace_len = usize::try_from(namespace_len)
654 .map_err(|_| SalamanderError::Migration("v0.1 namespace length overflow".into()))?;
655 let body_start = 24usize
656 .checked_add(namespace_len)
657 .ok_or_else(|| SalamanderError::Migration("v0.1 namespace length overflow".into()))?;
658 if body_start > payload.len() {
659 return Err(SalamanderError::Migration(format!(
660 "v0.1 namespace at {offset} is truncated"
661 )));
662 }
663 let namespace = std::str::from_utf8(&payload[24..body_start])
664 .map_err(|_| {
665 SalamanderError::Migration(format!("v0.1 namespace at {offset} is not UTF-8"))
666 })?
667 .to_string();
668 Ok(LegacyEvent {
669 offset,
670 timestamp_ms,
671 namespace,
672 body: payload[body_start..].to_vec(),
673 })
674}
675
676fn fingerprint_source(manifest: &[u8], paths: &[(u64, PathBuf)]) -> Result<[u8; 16]> {
677 let mut state = [0xcbf2_9ce4_8422_2325u64, 0x8422_2325_cbf2_9ce4u64];
678 hash_bytes(&mut state, manifest);
679 for (_, path) in paths {
680 let mut file = File::open(path)?;
681 let mut buffer = [0u8; 64 * 1024];
682 loop {
683 let read = file.read(&mut buffer)?;
684 if read == 0 {
685 break;
686 }
687 hash_bytes(&mut state, &buffer[..read]);
688 }
689 }
690 let mut identity = [0; 16];
691 identity[..8].copy_from_slice(&state[0].to_le_bytes());
692 identity[8..].copy_from_slice(&state[1].to_le_bytes());
693 Ok(identity)
694}
695
696fn hash_bytes(state: &mut [u64; 2], bytes: &[u8]) {
697 for byte in bytes {
698 state[0] ^= u64::from(*byte);
699 state[0] = state[0].wrapping_mul(0x0000_0100_0000_01b3);
700 state[1] ^= u64::from(*byte).rotate_left(1);
701 state[1] = state[1].wrapping_mul(0x9e37_79b1_85eb_ca87);
702 }
703}
704
705fn hex(bytes: &[u8]) -> String {
706 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
707}
708
709fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> {
710 let parent = path
711 .parent()
712 .ok_or_else(|| SalamanderError::Migration("marker has no parent".into()))?;
713 let tmp = path.with_extension("tmp");
714 let bytes = serde_json::to_vec_pretty(value)
715 .map_err(|error| SalamanderError::Migration(error.to_string()))?;
716 {
717 let mut file = OpenOptions::new()
718 .create(true)
719 .truncate(true)
720 .write(true)
721 .open(&tmp)?;
722 file.write_all(&bytes)?;
723 file.sync_all()?;
724 }
725 std::fs::rename(tmp, path)?;
726 sync_dir(parent)
727}
728
729#[cfg(unix)]
730fn sync_dir(path: &Path) -> Result<()> {
731 File::open(path)?.sync_all()?;
732 Ok(())
733}
734
735#[cfg(not(unix))]
736fn sync_dir(_path: &Path) -> Result<()> {
737 Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn deterministic_import_ids_change_with_offset() {
746 let source = [7; 16];
747 assert_eq!(derive_import_id(source, 3), derive_import_id(source, 3));
748 assert_ne!(derive_import_id(source, 3), derive_import_id(source, 4));
749 }
750
751 #[test]
752 fn legacy_event_split_preserves_body_bytes() {
753 let mut payload = Vec::new();
754 payload.extend_from_slice(&99u64.to_le_bytes());
755 payload.extend_from_slice(&1234u64.to_le_bytes());
756 payload.extend_from_slice(&2u64.to_le_bytes());
757 payload.extend_from_slice(b"ns");
758 payload.extend_from_slice(&[9, 8, 7]);
759 let event = split_legacy_event(5, &payload).unwrap();
760 assert_eq!(event.offset, 5);
761 assert_eq!(event.timestamp_ms, 1234);
762 assert_eq!(event.namespace, "ns");
763 assert_eq!(event.body, vec![9, 8, 7]);
764 }
765}