Skip to main content

salamander/
migration.rs

1//! Read-only v0.1 to v2 offline migration.
2
3use 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/// Outcome of a v1-to-v2 import, returned by [`migrate_v1`].
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct MigrationReport {
23    /// Records read from the v1 source.
24    pub source_records: u64,
25    /// Records already present in the destination from a prior run.
26    pub previously_imported: u64,
27    /// Records imported by this run.
28    pub newly_imported: u64,
29    /// The destination's head after the import.
30    pub destination_head: u64,
31}
32
33/// Outcome of a legacy-fork-marker conversion, returned by
34/// [`migrate_legacy_branches`].
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct BranchMigrationReport {
37    /// Records read from the source.
38    pub source_records: u64,
39    /// Records rewritten into the destination.
40    pub migrated_records: u64,
41    /// Legacy fork-marker records dropped during conversion.
42    pub removed_marker_records: u64,
43    /// Engine-owned branches created from the markers.
44    pub branches_created: u64,
45    /// The destination's head after the conversion.
46    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
59/// Rewrite a pre-WP-03 v2 database into a new database whose fork lineage is
60/// represented exclusively by engine-owned branch metadata.
61pub 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
358/// Imports a v1 database directory into a fresh v2 directory offline.
359///
360/// The import is resumable and verified: imported events receive
361/// deterministic IDs derived from the source identity and offset, so a
362/// repeated run is idempotent and produces an identical result.
363pub 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}