Skip to main content

dynoxide/import/
mod.rs

1//! Import CLI for DynamoDB Export data.
2//!
3//! Parses DynamoDB Export JSON Lines files, optionally applies anonymisation
4//! rules, and imports the data into a Dynoxide SQLite database.
5//!
6//! ## Pipeline
7//!
8//! 1. Parse TOML config (validate all rules upfront)
9//! 2. Source table schemas from `--schema <file>`, and the data model from
10//!    `--data-model <file>` when keys are built from attributes
11//! 3. Create tables in output SQLite database
12//! 4. For each table: read JSON Lines → parse → anonymise → rebuild templated
13//!    keys → batch insert
14//! 5. VACUUM (compact the SQLite file)
15//! 6. Optionally compress with zstd
16
17pub(crate) mod anonymise;
18pub(crate) mod config;
19pub(crate) mod consistency;
20pub(crate) mod keys;
21pub(crate) mod notice;
22pub(crate) mod parser;
23pub(crate) mod schema;
24
25use crate::{Database, ImportOptions};
26use consistency::ConsistencyMap;
27use indicatif::{ProgressBar, ProgressStyle};
28use std::collections::HashSet;
29
30pub use notice::{Concern, Notice};
31use std::path::Path;
32
33/// Errors from the import pipeline.
34#[derive(Debug)]
35pub enum ImportError {
36    /// Configuration or validation error (e.g., invalid TOML, missing schema).
37    Config(String),
38    /// I/O or parsing error during data import.
39    Data(String),
40    /// Database error during table creation or item insertion.
41    Database(String),
42}
43
44impl std::fmt::Display for ImportError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            ImportError::Config(msg) => write!(f, "{msg}"),
48            ImportError::Data(msg) => write!(f, "{msg}"),
49            ImportError::Database(msg) => write!(f, "{msg}"),
50        }
51    }
52}
53
54impl std::error::Error for ImportError {}
55
56impl From<String> for ImportError {
57    fn from(s: String) -> Self {
58        ImportError::Data(s)
59    }
60}
61
62/// Configuration for the import operation.
63pub struct ImportCommand {
64    /// Source directory containing export files.
65    pub source: std::path::PathBuf,
66    /// Output SQLite database path (required for file-based import, None for in-memory).
67    pub output: Option<std::path::PathBuf>,
68    /// Schema file path (DescribeTable JSON format).
69    pub schema: std::path::PathBuf,
70    /// Optional anonymisation rules TOML file.
71    pub rules: Option<std::path::PathBuf>,
72    /// Optional OneTable schema. With it, keys built from attributes
73    /// (`user#${email}`) are rebuilt from the anonymised attributes after the
74    /// rules run, so the entity prefix survives and key and attribute agree.
75    pub data_model: Option<std::path::PathBuf>,
76    /// Optional table name filter (comma-separated).
77    pub tables: Option<Vec<String>>,
78    /// Optional zstd compression of output (only valid with file output).
79    pub compress: bool,
80    /// Overwrite existing output file without prompting.
81    pub force: bool,
82    /// Continue importing when a batch fails (default: fail-fast).
83    /// When true, batch errors are recorded as warnings and import continues.
84    /// When false (default), the first batch error aborts the import.
85    pub continue_on_error: bool,
86}
87
88/// Result of an import operation.
89#[derive(Debug)]
90pub struct ImportSummary {
91    /// Per-table import statistics.
92    pub tables: Vec<TableImportResult>,
93    /// Total items imported across all tables.
94    pub total_items: usize,
95    /// Total bytes imported.
96    pub total_bytes: usize,
97    /// Total lines skipped due to parse errors.
98    pub total_skipped: usize,
99    /// Every message the import raised, each with its concern.
100    pub notices: Vec<Notice>,
101    /// The messages of `notices`, in order. Kept for readers that want text.
102    pub warnings: Vec<String>,
103    /// The messages of the notices whose concern is [`Concern::Exposure`]: an
104    /// original value was seen reaching the output. These are the reason the
105    /// run's exit code is not 0, so a pipeline notices without reading prose.
106    pub exposures: Vec<String>,
107    /// Output file path (may differ from input if compressed). None for in-memory imports.
108    pub output_path: Option<std::path::PathBuf>,
109}
110
111impl ImportSummary {
112    /// Record a notice. The `warnings` and `exposures` views are filled from
113    /// the notices once the run ends, so nothing else writes to them.
114    fn notice(&mut self, notice: Notice) {
115        self.notices.push(notice);
116    }
117
118    /// Fill the text views from the notices. Called once, at the end.
119    fn finish(&mut self) {
120        self.warnings = self.notices.iter().map(|n| n.message.clone()).collect();
121        self.exposures = self
122            .notices
123            .iter()
124            .filter(|n| n.concern == Concern::Exposure)
125            .map(|n| n.message.clone())
126            .collect();
127    }
128}
129
130/// Per-table import result.
131#[derive(Debug)]
132pub struct TableImportResult {
133    pub table_name: String,
134    pub items_imported: usize,
135    pub bytes_imported: usize,
136    pub lines_skipped: usize,
137}
138
139/// Scaffold empty tables from a DynamoDB DescribeTable JSON schema file.
140///
141/// Reads the schema file, creates each table defined in it, and skips any
142/// tables that already exist. Returns the number of tables created.
143///
144/// The schema file format is identical to `import --schema`: a JSON file
145/// containing a single `aws dynamodb describe-table` response or an array
146/// of them.
147pub fn scaffold_from_schema(db: &Database, path: &std::path::Path) -> Result<usize, ImportError> {
148    let (schemas, schema_json) = schema::load_schemas(path).map_err(ImportError::Config)?;
149    let mut created = 0;
150    for table_schema in &schemas {
151        let create_request = build_create_request(&schema_json, &table_schema.table_name)?;
152        match db.create_table(create_request) {
153            Ok(_) => created += 1,
154            Err(crate::errors::DynoxideError::ResourceInUseException(_)) => {} // already exists
155            Err(e) => return Err(ImportError::Database(e.to_string())),
156        }
157    }
158    Ok(created)
159}
160
161/// Build a `CreateTableRequest` for `table_name` from raw schema JSON.
162///
163/// Deserializes through `CreateTableRequest`'s `Deserialize` impl (rather than
164/// building the struct by hand) so GlobalSecondaryIndexes and
165/// LocalSecondaryIndexes are picked up from the schema. Shared by `run_into`
166/// and `scaffold_from_schema` so the two can't drift apart on which fields a
167/// schema-sourced table ends up with.
168fn build_create_request(
169    schema_json: &serde_json::Value,
170    table_name: &str,
171) -> Result<crate::actions::create_table::CreateTableRequest, String> {
172    let table_json = find_table_json(schema_json, table_name)
173        .ok_or_else(|| format!("Schema JSON not found for table '{table_name}'"))?;
174
175    serde_json::from_value(table_json)
176        .map_err(|e| format!("Failed to deserialize schema for '{table_name}': {e}"))
177}
178
179/// Execute the import pipeline into a caller-provided database.
180///
181/// This is the core import logic: database-agnostic. The caller is
182/// responsible for creating the database and any post-import steps
183/// (VACUUM, compression). This makes import usable with both file-backed
184/// and in-memory databases.
185pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
186    // 1. Load and validate anonymisation rules (if provided)
187    let (rules, consistency_config) = if let Some(ref rules_path) = cmd.rules {
188        let (rules, consistency) =
189            config::load_and_validate(rules_path).map_err(ImportError::Config)?;
190        eprintln!(
191            "Loaded {} anonymisation rules from {}",
192            rules.len(),
193            rules_path.display()
194        );
195        (rules, consistency)
196    } else {
197        (Vec::new(), None)
198    };
199
200    let consistency_fields: HashSet<String> = consistency_config
201        .as_ref()
202        .map(|c| c.fields.iter().cloned().collect())
203        .unwrap_or_default();
204
205    let data_model = match cmd.data_model {
206        Some(ref path) => {
207            let model =
208                crate::schema::onetable::parse_onetable_file(path).map_err(ImportError::Config)?;
209            eprintln!(
210                "Loaded data model: {} ({} entities) from {}",
211                model.schema_format,
212                model.entities.len(),
213                path.display()
214            );
215            Some(model)
216        }
217        None => None,
218    };
219
220    // 2. Load table schemas (returns both parsed schemas and raw JSON)
221    let (schemas, schema_json) = schema::load_schemas(&cmd.schema)?;
222    eprintln!(
223        "Loaded {} table schemas from {}",
224        schemas.len(),
225        cmd.schema.display()
226    );
227    refuse_scopes_outside_the_schema(&rules, &schemas)?;
228
229    // 3. Discover export files
230    let table_filter = cmd.tables.as_deref();
231    let export_files =
232        parser::discover_export_files(&cmd.source, table_filter).map_err(ImportError::Config)?;
233
234    if export_files.is_empty() {
235        return Err(ImportError::Config(format!(
236            "No export files found in {}. Expected DynamoDB Export directory structure \
237             (<dir>/<TableName>/data/*.json[.gz] or *.jsonl[.gz]) or a flat directory \
238             of the same files.",
239            cmd.source.display()
240        )));
241    }
242
243    // A OneTable model describes one table. Built for a second one, every
244    // row of it matches no entity, and the run exits 3 for data the model
245    // was never about, which teaches the operator to pass the flag that
246    // turns the exit code off. Only with rules, though: without them no key
247    // is rebuilt and the model is never asked about a row, and a run given
248    // the model only so the output can be served covers every table.
249    if data_model.is_some() && !rules.is_empty() && export_files.len() > 1 {
250        let names: Vec<String> = export_files.iter().map(|(name, _)| name.clone()).collect();
251        return Err(ImportError::Config(format!(
252            "a data model describes one table, and this run covers {}. Pass --tables with \
253             the one the model describes",
254            quoted_names(&names)
255        )));
256    }
257
258    // Build a schema lookup map
259    let schema_map: std::collections::HashMap<&str, &schema::TableSchema> =
260        schemas.iter().map(|s| (s.table_name.as_str(), s)).collect();
261
262    // 4. Create tables from schemas
263    for (table_name, _) in &export_files {
264        if !schema_map.contains_key(table_name.as_str()) {
265            return Err(ImportError::Config(format!(
266                "No schema found for table '{}'. Available schemas: {}",
267                table_name,
268                schemas
269                    .iter()
270                    .map(|s| s.table_name.as_str())
271                    .collect::<Vec<_>>()
272                    .join(", ")
273            )));
274        }
275
276        let create_request = build_create_request(&schema_json, table_name)?;
277
278        db.create_table(create_request)
279            .map_err(|e| format!("Failed to create table '{}': {e}", table_name))?;
280    }
281
282    // 5. Enable bulk-loading PRAGMAs (safe: fresh DB, can re-import on crash)
283    db.enable_bulk_loading()
284        .map_err(|e| format!("Failed to enable bulk loading: {e}"))?;
285    // `run_into` writes into a database the caller supplied and goes on using,
286    // so however this returns, their connection must not be left on
287    // `synchronous = OFF`. There are several error exits between here and the
288    // end, and restoring at each one by hand is how four of them were missed.
289    let _restore_pragmas = BulkLoading { db };
290
291    // 6. Import data for each table
292    let mut summary = ImportSummary {
293        tables: Vec::new(),
294        total_items: 0,
295        total_bytes: 0,
296        total_skipped: 0,
297        notices: Vec::new(),
298        warnings: Vec::new(),
299        exposures: Vec::new(),
300        output_path: cmd.output.clone(),
301    };
302
303    // Keys can move two ways: rebuilt from a template, or rewritten by a rule
304    // that names a key attribute. Either can land two source items on one
305    // primary key, which is exactly the assumption `import_items_fresh`
306    // trades away: it skips the GSI delete-before-insert, so an overwritten
307    // base row would leave its old index entry behind and index queries
308    // would answer from a row that no longer exists. Neither happens without
309    // rules, so a plain import keeps the fast path that assumes every key is
310    // unique.
311    let rules_touch_a_key = rules_touch(&rules, &schemas, extract_key_attrs);
312    let rebuilds_keys = !rules.is_empty() && (data_model.is_some() || rules_touch_a_key);
313
314    preflight_cautions(
315        &mut summary,
316        &rules,
317        &schemas,
318        &consistency_fields,
319        data_model.is_some(),
320        rules_touch_a_key,
321    );
322
323    let mut run = TableImport {
324        db,
325        rules: &rules,
326        consistency_fields: &consistency_fields,
327        data_model: data_model.as_ref(),
328        rebuilds_keys,
329        continue_on_error: cmd.continue_on_error,
330        consistency_map: ConsistencyMap::new(),
331        seen_warnings: HashSet::new(),
332        rule_work: vec![Default::default(); rules.len()],
333    };
334
335    for (table_name, files) in &export_files {
336        let table_schema = schema_map.get(table_name.as_str()).unwrap();
337        run.import_table(table_name, files, table_schema, &mut summary)?;
338    }
339
340    report_unused_rules(&mut summary, &rules, &run.rule_work, &export_files);
341
342    // 7. Restore normal PRAGMAs (important if DB will be served after import)
343    db.disable_bulk_loading()
344        .map_err(|e| format!("Failed to disable bulk loading: {e}"))?;
345
346    // Report consistency map stats
347    if run.consistency_map.field_count() > 0 {
348        eprintln!(
349            "Consistency map: {} fields, {} total mappings",
350            run.consistency_map.field_count(),
351            run.consistency_map.total_mappings()
352        );
353    }
354
355    summary.finish();
356    Ok(summary)
357}
358
359/// Whether a rule rewrites, on a table it applies to, an attribute `keys_of`
360/// picks out of that table's schema.
361///
362/// Only a rule that applies to a table whose key it names counts: one scoped
363/// to another table is not rewriting this one's keys.
364fn rules_touch(
365    rules: &[config::ValidatedRule],
366    schemas: &[schema::TableSchema],
367    keys_of: fn(&crate::actions::create_table::CreateTableRequest) -> Vec<String>,
368) -> bool {
369    rules.iter().any(|rule| match rule.path.first() {
370        Some(crate::expressions::PathElement::Attribute(name)) => schemas
371            .iter()
372            .any(|s| rule.applies_to(&s.table_name) && keys_of(&s.create_request).contains(name)),
373        _ => false,
374    })
375}
376
377/// The cautions a run raises before it reads an item, from the rules and the
378/// schemas alone.
379fn preflight_cautions(
380    summary: &mut ImportSummary,
381    rules: &[config::ValidatedRule],
382    schemas: &[schema::TableSchema],
383    consistency_fields: &HashSet<String>,
384    has_data_model: bool,
385    rules_touch_a_key: bool,
386) {
387    // A seeded fake derives its value and does not populate the consistency
388    // map, because it does not need to. That only holds while every rule
389    // writing a given field agrees: mix a seeded rule with an unseeded one, or
390    // two different seeds or generators, and the same input can leave with two
391    // different values depending on which rule matched. Nothing downstream
392    // would show that, so say it here.
393    for message in mixed_consistency_rules(rules, consistency_fields) {
394        summary.notice(Notice::caution(message));
395    }
396
397    if rules.is_empty() && has_data_model {
398        summary.notice(Notice::caution(
399            "a data model was given but no rules, so nothing was anonymised and no key was \
400             rebuilt. The model is only used to re-render keys after a rule has changed an \
401             attribute one is built from",
402        ));
403    }
404
405    // A caution, not an observation: without a model the importer cannot tell
406    // whether any key here is built from an attribute a rule rewrites, so this
407    // is raised on every rules-only run. An exposure has to be something the
408    // run saw happen, or the common case exits non-zero and the flag that
409    // turns that off becomes the thing everyone passes.
410    if !rules.is_empty() && !has_data_model {
411        summary.notice(Notice::caution(
412            "rules rewrite attributes only: a key built from an attribute (CUSTOMER#${email}) \
413             keeps its original value. Pass --data-model <onetable.json> to rebuild keys \
414             from entity templates after anonymisation",
415        ));
416    }
417
418    if rules_touch(rules, schemas, index_key_attrs) && !has_data_model {
419        summary.notice(Notice::caution(
420            "a rule rewrites an index key attribute directly. No row is lost, but rows that \
421             an index returned together are moved apart, and without a data model nothing \
422             checks whether they still belong together",
423        ));
424    }
425
426    if rules_touch_a_key && !has_data_model {
427        summary.notice(Notice::caution(
428            "a rule rewrites a key attribute directly, and without a data model nothing \
429             counts what that costs: two items whose rewritten key comes out the same land \
430             on one row and the later one replaces the earlier. Overwrites are only counted \
431             when --data-model is given, so the item count below will not show what was lost",
432        ));
433    }
434}
435
436/// What every table of one run shares.
437///
438/// The consistency map, the dedupe of item-level warnings and the per-rule
439/// tallies all span tables: a value seen in one table must leave the same way
440/// from the next, and only the whole run can say that a rule did nothing at
441/// all.
442struct TableImport<'a> {
443    db: &'a Database,
444    rules: &'a [config::ValidatedRule],
445    consistency_fields: &'a HashSet<String>,
446    data_model: Option<&'a crate::schema::DataModel>,
447    /// Whether the run takes the write path that keeps the indexes right when
448    /// one item's key lands on another's.
449    rebuilds_keys: bool,
450    continue_on_error: bool,
451    consistency_map: ConsistencyMap,
452    seen_warnings: HashSet<String>,
453    /// Across every table, not per table: a rule can legitimately touch nothing
454    /// in one table and every item of the next, so only the whole run can say
455    /// that a rule did nothing at all.
456    rule_work: Vec<anonymise::RuleWork>,
457}
458
459impl TableImport<'_> {
460    /// Read one table's export files, anonymise and insert the items, and
461    /// record what the table had to say.
462    fn import_table(
463        &mut self,
464        table_name: &str,
465        files: &[std::path::PathBuf],
466        table_schema: &schema::TableSchema,
467        summary: &mut ImportSummary,
468    ) -> Result<(), ImportError> {
469        let rules = self.rules;
470        let key_attrs = extract_key_attrs(&table_schema.create_request);
471        // A data model with no rules has nothing to rebuild from: keys are
472        // only re-rendered after an anonymisation moved something, so the
473        // deriver would collect model-versus-schema warnings and then never
474        // be asked to plan a single item. Reporting those reads as diagnostics
475        // about work the run did, and it did none.
476        let key_deriver = match self.data_model.filter(|_| !rules.is_empty()) {
477            Some(model) => {
478                let (deriver, warnings) = keys::KeyDeriver::new(
479                    model,
480                    &table_schema.create_request,
481                    rules,
482                    self.consistency_fields,
483                )
484                .map_err(|e| {
485                    // The warnings collected so far are part of the
486                    // explanation, and the summary that carries them does
487                    // not survive an error return.
488                    for n in &summary.notices {
489                        eprintln!("  - {n}");
490                    }
491                    ImportError::Config(format!("data model: {e}"))
492                })?;
493                for notice in warnings {
494                    summary.notice(notice.for_table(table_name));
495                }
496                Some(deriver)
497            }
498            None => None,
499        };
500        let mut landed = Landed {
501            key_deriver,
502            mask_passthroughs: std::collections::HashMap::new(),
503            items: 0,
504            bytes: 0,
505        };
506
507        let file_count = files.len();
508        eprintln!("Importing table '{}' ({} files)...", table_name, file_count);
509
510        let pb = ProgressBar::new_spinner();
511        pb.set_style(
512            ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {msg}")
513                .unwrap()
514                .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
515        );
516        pb.set_message(format!("{}: parsing...", table_name));
517
518        let mut table_skipped = 0usize;
519        let mut batch_error: Option<String> = None;
520
521        const BATCH_SIZE: usize = 10_000;
522
523        for file_path in files {
524            let mut pending = Pending::with_capacity(BATCH_SIZE, rules.len());
525
526            let stats = parser::parse_export_file_streaming(file_path, |mut item| {
527                // Skip processing if we've already hit a fatal batch error
528                if batch_error.is_some() {
529                    return;
530                }
531
532                // Apply anonymisation rules, then rebuild any key the data
533                // model says is built from the attributes just rewritten
534                if !rules.is_empty() {
535                    let mut warnings = Vec::new();
536                    let plan = landed
537                        .key_deriver
538                        .as_ref()
539                        .and_then(|d| d.plan(&item, &mut pending.observed, &mut warnings));
540                    let (rule_warnings, rewritten) = anonymise::apply_rules(
541                        &mut item,
542                        table_name,
543                        rules,
544                        &mut self.consistency_map,
545                        self.consistency_fields,
546                        &key_attrs,
547                        &mut anonymise::RuleTally {
548                            mask_passthroughs: &mut pending.mask_passthroughs,
549                            rule_work: &mut pending.rule_work,
550                        },
551                    );
552                    warnings.extend(rule_warnings);
553                    match (landed.key_deriver.as_ref(), plan) {
554                        (Some(deriver), Some(plan)) => {
555                            deriver.apply(
556                                &plan,
557                                &rewritten,
558                                &mut item,
559                                &mut pending.observed,
560                                &mut warnings,
561                            );
562                        }
563                        // An item that matched no entity keeps its keys, but a
564                        // rebuilt key can still land on them, and that row is
565                        // lost like any other. Record it so the overwrite is
566                        // counted rather than invisible.
567                        (Some(deriver), None) => {
568                            deriver.note_primary_key(&item, &mut pending.observed)
569                        }
570                        (None, _) => {}
571                    }
572                    for notice in warnings {
573                        // Named for the table, both so the reader knows
574                        // where it came from and so the cross-table dedupe
575                        // cannot swallow table B's copy of a notice table A
576                        // already raised.
577                        pending.raise(notice.for_table(table_name), &self.seen_warnings);
578                    }
579                }
580                pending.items.push(item);
581
582                // Flush batch when full
583                if pending.items.len() >= BATCH_SIZE {
584                    let full = std::mem::replace(
585                        &mut pending,
586                        Pending::with_capacity(BATCH_SIZE, rules.len()),
587                    );
588                    if let Err(msg) = self.land(table_name, full, &mut landed, summary) {
589                        if self.continue_on_error {
590                            summary.notice(Notice::caution(msg));
591                        } else {
592                            batch_error = Some(msg);
593                            return;
594                        }
595                    }
596                    pb.set_message(format!("{}: {} items", table_name, landed.items));
597                    pb.tick();
598                }
599            })?;
600
601            // Propagate batch error after the streaming callback completes
602            if let Some(err) = batch_error.take() {
603                pb.abandon_with_message(format!("{}: FAILED", table_name));
604                return Err(ImportError::Database(err));
605            }
606
607            table_skipped += stats.skipped;
608            // A skipped line is a row absent from the output, which is
609            // reported and counted but is not an original value reaching it.
610            for warning in stats.warnings {
611                summary.notice(Notice::caution(warning));
612            }
613
614            // Flush remaining items. Under --continue-on-error this batch is
615            // treated as the others were: the flag covered every full batch
616            // and then the last partial one failed the run anyway.
617            if !pending.items.is_empty() {
618                if let Err(msg) = self.land(table_name, pending, &mut landed, summary) {
619                    if self.continue_on_error {
620                        summary.notice(Notice::caution(msg));
621                    } else {
622                        pb.abandon_with_message(format!("{}: FAILED", table_name));
623                        return Err(ImportError::Database(msg));
624                    }
625                }
626                pb.set_message(format!("{}: {} items", table_name, landed.items));
627                pb.tick();
628            }
629        }
630
631        if let Some(deriver) = landed.key_deriver.as_mut() {
632            // Filed before the join check below, so that a run the check
633            // fails still prints everything else the table had to say.
634            for notice in deriver.take_notices() {
635                summary.notice(notice.for_table(table_name));
636            }
637
638            // Both entities of a shared key attribute turned up, so the join
639            // between them is broken in the output rather than merely at risk.
640            // The CLI writes to a temporary file that an error discards, so
641            // failing here costs the run's time, not a half-anonymised
642            // database. `run_into` writes into the caller's database and
643            // leaves earlier tables in place.
644            let join_breaks = deriver.join_breaks();
645            if let Some(first) = join_breaks.first() {
646                // The notices collected so far are what explain the failure;
647                // returning without them leaves the operator with a verdict
648                // and no evidence.
649                for n in &summary.notices {
650                    eprintln!("  - {n}");
651                }
652                // The BulkLoading guard in `run_into` restores the PRAGMAs.
653                return Err(ImportError::Config(format!(
654                    "table '{}': {}{}",
655                    table_name,
656                    first,
657                    if join_breaks.len() > 1 {
658                        format!(" (and {} more)", join_breaks.len() - 1)
659                    } else {
660                        String::new()
661                    }
662                )));
663            }
664        }
665
666        let mut left_whole: Vec<(&String, &usize)> = landed.mask_passthroughs.iter().collect();
667        left_whole.sort();
668        for (path, count) in left_whole {
669            summary.notice(Notice::exposure(format!(
670                "table '{table_name}': {count} items kept '{path}' as it arrived \
671                 because the value was no shorter than the characters the mask keeps, so \
672                 those rows carry the original value"
673            )));
674        }
675
676        pb.finish_with_message(format!(
677            "{}: {} items, {} bytes{}",
678            table_name,
679            landed.items,
680            format_bytes(landed.bytes),
681            if table_skipped > 0 {
682                format!(", {} skipped", table_skipped)
683            } else {
684                String::new()
685            }
686        ));
687
688        summary.tables.push(TableImportResult {
689            table_name: table_name.to_string(),
690            items_imported: landed.items,
691            bytes_imported: landed.bytes,
692            lines_skipped: table_skipped,
693        });
694        summary.total_items += landed.items;
695        summary.total_bytes += landed.bytes;
696        summary.total_skipped += table_skipped;
697        Ok(())
698    }
699
700    /// Insert one batch and, if it landed, keep what was seen of it.
701    ///
702    /// The insert is one transaction, so a failure means none of the
703    /// batch's rows reached the output. What they said about exposures,
704    /// collisions and joins is dropped with them, and a caution first raised
705    /// for one of them is raised again by the next row that earns it. The
706    /// error is the message the run reports; whether the run goes on is the
707    /// caller's decision.
708    fn land(
709        &mut self,
710        table_name: &str,
711        pending: Pending,
712        landed: &mut Landed,
713        summary: &mut ImportSummary,
714    ) -> Result<(), String> {
715        let result = insert_items(self.db, self.rebuilds_keys, table_name, pending.items)
716            .map_err(|e| format!("Batch import error for '{}': {e}", table_name))?;
717        landed.items += result.items_imported;
718        landed.bytes += result.bytes_imported;
719        if let Some(deriver) = landed.key_deriver.as_mut() {
720            deriver.commit(pending.observed);
721        }
722        for (path, count) in pending.mask_passthroughs {
723            *landed.mask_passthroughs.entry(path).or_insert(0) += count;
724        }
725        for (total, seen) in self.rule_work.iter_mut().zip(pending.rule_work) {
726            total.add(seen);
727        }
728        for notice in pending.notices {
729            if self.seen_warnings.insert(notice.message.clone()) {
730                summary.notice(notice);
731            }
732        }
733        Ok(())
734    }
735}
736
737/// One batch of items on its way to the output, with everything the run
738/// observed about them held back until the batch is known to have landed.
739///
740/// The insert is one transaction, and a failure rolls all of it back. A
741/// notice about a row that never reached the output would be false, and a
742/// key remembered from such a row would be compared against rows that did
743/// land. So nothing a batch says is believed until its insert returns.
744struct Pending {
745    items: Vec<crate::types::Item>,
746    /// What the key deriver saw, for it to fold in once the batch lands.
747    observed: keys::Batch,
748    /// Notices the items raised, in the order they were raised, each once.
749    notices: Vec<Notice>,
750    /// The messages of `notices`, so a batch whose every item raises one
751    /// caution holds it once rather than ten thousand times.
752    raised: HashSet<String>,
753    /// Values a mask rule left whole, by the rule's path.
754    mask_passthroughs: std::collections::HashMap<String, usize>,
755    /// What each rule did to the batch's items, one entry per rule. A rule
756    /// that only ever rewrote rows that rolled back has anonymised nothing
757    /// in the output, and the run has to be able to say so.
758    rule_work: Vec<anonymise::RuleWork>,
759}
760
761impl Pending {
762    fn with_capacity(items: usize, rules: usize) -> Self {
763        Pending {
764            items: Vec::with_capacity(items),
765            observed: keys::Batch::default(),
766            notices: Vec::new(),
767            raised: HashSet::new(),
768            mask_passthroughs: std::collections::HashMap::new(),
769            rule_work: vec![Default::default(); rules],
770        }
771    }
772
773    /// Hold a notice for the batch, unless a batch that landed already
774    /// raised it or this one has.
775    fn raise(&mut self, notice: Notice, already_raised: &HashSet<String>) {
776        if already_raised.contains(&notice.message) {
777            return;
778        }
779        if self.raised.insert(notice.message.clone()) {
780            self.notices.push(notice);
781        }
782    }
783}
784
785/// What one table's landed batches add up to.
786struct Landed {
787    key_deriver: Option<keys::KeyDeriver>,
788    /// Values a mask rule left whole, by the rule's path. Bounded by the
789    /// number of mask rules, not by item count.
790    mask_passthroughs: std::collections::HashMap<String, usize>,
791    items: usize,
792    bytes: usize,
793}
794
795/// Insert a batch on the write path the run chose.
796///
797/// A run whose keys can move takes the path that deletes an overwritten
798/// row's index entries first. One whose keys come from the export unchanged
799/// cannot collide, and keeps the faster path that assumes every key is
800/// unique.
801fn insert_items(
802    db: &Database,
803    rebuilds_keys: bool,
804    table: &str,
805    batch: Vec<crate::types::Item>,
806) -> crate::Result<crate::ImportResult> {
807    if rebuilds_keys {
808        db.import_items(table, batch, ImportOptions::default())
809    } else {
810        db.import_items_fresh(table, batch, ImportOptions::default())
811    }
812}
813
814/// Report each rule that did nothing in the whole run.
815///
816/// A rules file is the operator's statement of which attributes hold
817/// personal data. A rule that never fired means that statement was not
818/// carried out, and nothing else in the output says so: the item count is
819/// full, no warning is raised, and the run exits 0. A misspelt path or a
820/// match expression that fits none of the data both take exactly that
821/// shape, which is why this is reported per rule rather than in aggregate.
822fn report_unused_rules(
823    summary: &mut ImportSummary,
824    rules: &[config::ValidatedRule],
825    rule_work: &[anonymise::RuleWork],
826    export_files: &[(String, Vec<std::path::PathBuf>)],
827) {
828    for (index, work) in rule_work.iter().enumerate() {
829        let number = index + 1;
830        let path = rules
831            .get(index)
832            .map(|r| crate::expressions::format_path_for_error(&r.path))
833            .unwrap_or_default();
834        // A rule scoped to tables this run did not read had no work to do,
835        // so its silence is not a value left behind. Without the scope a
836        // shared rules file run with --tables raised an exposure on every
837        // run for the rules aimed at the tables left out.
838        if let Some(tables) = rules.get(index).and_then(|r| r.tables.as_ref())
839            && !tables
840                .iter()
841                .any(|t| export_files.iter().any(|(name, _)| name == t))
842        {
843            summary.notice(Notice::caution(format!(
844                "rule {number} (path '{path}') is scoped to {} and none of those tables is in \
845                 this run, so it was not applied",
846                quoted_names(tables)
847            )));
848            continue;
849        }
850        if work.matched == 0 {
851            summary.notice(Notice::exposure(format!(
852                "rule {number} (path '{path}') matched no item in any table, so nothing \
853                 was anonymised by it. Check the match expression, and check the path is \
854                 spelled the way the export spells it"
855            )));
856        } else if work.rewrote == 0 {
857            summary.notice(Notice::exposure(format!(
858                "rule {number} matched {} items but rewrote none of them: not one carried \
859                 the path '{path}'. The attribute it names is absent from this data",
860                work.matched
861            )));
862        }
863        // No branch for "matched some items that did not carry the path": a
864        // match broader than the path is the shape this page documents, so
865        // most correct rules would raise it on every run and the warnings
866        // would stop being read.
867    }
868}
869
870/// Execute the import pipeline with file-based output.
871///
872/// Creates a new database at a temporary path, imports data, VACUUMs,
873/// then atomically renames to the final output path. If the import fails
874/// at any point, the temp file is cleaned up automatically and any
875/// existing output file is preserved.
876pub fn run(cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
877    let output = cmd
878        .output
879        .as_ref()
880        .ok_or_else(|| ImportError::Config("output path required for file-based import".into()))?;
881
882    // Check the path this run will write, which under --compress is the
883    // archive, not the database name given. Checking the wrong one let an
884    // existing archive be replaced without --force and blocked a compressed
885    // run on a stray database it was never going to touch.
886    let compress = cmd.compress;
887    let force = cmd.force;
888    let final_path = if compress {
889        output.with_extension("db.zst")
890    } else {
891        output.clone()
892    };
893    if final_path.exists() && !cmd.force {
894        return Err(ImportError::Config(format!(
895            "Output file '{}' already exists. Use --force to overwrite.",
896            final_path.display()
897        )));
898    }
899
900    let output_path = output.clone();
901
902    // Write to a temp file in the same directory as the output so that
903    // persist() can do an atomic rename (same filesystem). On failure,
904    // NamedTempFile's Drop cleans up automatically.
905    let output_dir = output_path.parent().unwrap_or(Path::new("."));
906    let tmp_file = tempfile::NamedTempFile::new_in(output_dir)
907        .map_err(|e| ImportError::Database(format!("Failed to create temp file: {e}")))?;
908    let tmp_path = tmp_file.path().to_path_buf();
909
910    // Close the temp file handle - Database::new will open it by path.
911    // Keep the NamedTempFile alive so it cleans up on error.
912    let tmp_file = tmp_file.into_temp_path();
913
914    let db = Database::new(
915        tmp_path
916            .to_str()
917            .ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
918    )
919    .map_err(|e| ImportError::Database(format!("Failed to create output database: {e}")))?;
920
921    let mut summary = run_into(&db, cmd)?;
922
923    // VACUUM for compact output.
924    // Drop the db and reopen to release any in-process state before compacting.
925    drop(db);
926    {
927        let db = Database::new(
928            tmp_path
929                .to_str()
930                .ok_or_else(|| ImportError::Config("Invalid temp path".to_string()))?,
931        )
932        .map_err(|e| ImportError::Database(format!("Failed to reopen database for VACUUM: {e}")))?;
933        db.vacuum()
934            .map_err(|e| ImportError::Database(format!("VACUUM failed: {e}")))?;
935    }
936    eprintln!("Database compacted.");
937
938    // Atomically move the temp file to the final output path.
939    // This overwrites any existing file (--force was already checked above).
940    // Compress before anything lands at the output path. Compressing after
941    // the rename left the uncompressed database at the final path when
942    // compression failed, on a run that then returned an error, which is the
943    // one shape "nothing is persisted on the error path" promised not to
944    // produce.
945    if compress {
946        let compressed_path = final_path;
947        let compressed_tmp = tempfile::NamedTempFile::new_in(output_dir)
948            .map_err(|e| ImportError::Database(format!("Failed to create temp file: {e}")))?
949            .into_temp_path();
950        let size = compress_to(&tmp_path, &compressed_tmp)?;
951        move_into_place(compressed_tmp, &compressed_path, force)?;
952        eprintln!("Compressed output: {}", format_bytes(size));
953        summary.output_path = Some(compressed_path);
954        return Ok(summary);
955    }
956
957    move_into_place(tmp_file, &output_path, force)?;
958    summary.output_path = Some(output_path);
959
960    Ok(summary)
961}
962
963/// Find the raw JSON for a specific table in the schema file.
964/// Converts from DescribeTable format (with "Table" wrapper) to CreateTableRequest format.
965fn find_table_json(schema_json: &serde_json::Value, table_name: &str) -> Option<serde_json::Value> {
966    let items: Vec<&serde_json::Value> = match schema_json {
967        serde_json::Value::Array(arr) => arr.iter().collect(),
968        obj @ serde_json::Value::Object(_) => vec![obj],
969        _ => return None,
970    };
971
972    for item in items {
973        let table = item.get("Table").unwrap_or(item);
974        if table.get("TableName").and_then(|v| v.as_str()) == Some(table_name) {
975            // Convert from DescribeTable format to CreateTableRequest format:
976            // strip the "Table" wrapper, then translate the fields whose shape
977            // differs between the two.
978            let mut table = table.clone();
979            unwrap_describe_table_shapes(&mut table);
980            return Some(table);
981        }
982    }
983    None
984}
985
986/// Translate DescribeTable-only shapes into their CreateTableRequest
987/// equivalents. DescribeTable wraps billing mode and table class in summary
988/// objects (`BillingModeSummary`, `TableClassSummary`), which CreateTable
989/// never reads, so both would otherwise fall back to their defaults. It also
990/// reports zeroed `ProvisionedThroughput` blocks on an on-demand table and
991/// its GSIs, which CreateTable rejects, so those are dropped too. The drop
992/// is gated on the billing mode having come from the summary: a schema
993/// already in CreateTable shape passes through untouched, so an inconsistent
994/// one still fails validation exactly as it would on the CreateTable API.
995pub(super) fn unwrap_describe_table_shapes(table: &mut serde_json::Value) {
996    let Some(obj) = table.as_object_mut() else {
997        return;
998    };
999
1000    let mut billing_mode_hoisted = false;
1001    for (summary_key, field_key) in [
1002        ("BillingModeSummary", "BillingMode"),
1003        ("TableClassSummary", "TableClass"),
1004    ] {
1005        if !obj.contains_key(field_key)
1006            && let Some(value) = obj.get(summary_key).and_then(|s| s.get(field_key))
1007        {
1008            let value = value.clone();
1009            obj.insert(field_key.to_string(), value);
1010            billing_mode_hoisted |= field_key == "BillingMode";
1011        }
1012    }
1013
1014    if billing_mode_hoisted
1015        && obj.get("BillingMode").and_then(|v| v.as_str()) == Some("PAY_PER_REQUEST")
1016    {
1017        obj.remove("ProvisionedThroughput");
1018        if let Some(gsis) = obj
1019            .get_mut("GlobalSecondaryIndexes")
1020            .and_then(|v| v.as_array_mut())
1021        {
1022            for gsi in gsis {
1023                if let Some(gsi) = gsi.as_object_mut() {
1024                    gsi.remove("ProvisionedThroughput");
1025                }
1026            }
1027        }
1028    }
1029}
1030
1031/// Consistency fields written by rules that do not agree on how they generate.
1032///
1033/// Returns one message per offending field. A field is fine when every rule
1034/// targeting it uses the same action shape; it is not when a seeded fake sits
1035/// beside an unseeded one, or beside a different seed or generator, because
1036/// the seeded rule bypasses the map the other one depends on. Two seeds, or
1037/// two salts, are two shapes as well: the secret is part of the derivation,
1038/// so the same input leaves with a different value under each. They are told
1039/// apart by position rather than by value, so the message never carries one.
1040fn mixed_consistency_rules(
1041    rules: &[config::ValidatedRule],
1042    consistency_fields: &HashSet<String>,
1043) -> Vec<String> {
1044    use crate::expressions::PathElement;
1045
1046    let mut messages = Vec::new();
1047    for field in consistency_fields {
1048        // Numbering has to tell two secrets apart, not keep them. Holding the
1049        // copies in the same wrapper as the original means they are wiped on
1050        // drop like every other copy of a salt or a seed.
1051        let mut secrets: Vec<zeroize::Zeroizing<Vec<u8>>> = Vec::new();
1052        let mut secret_number = |bytes: &[u8]| -> usize {
1053            match secrets.iter().position(|known| known.as_slice() == bytes) {
1054                Some(i) => i + 1,
1055                None => {
1056                    secrets.push(zeroize::Zeroizing::new(bytes.to_vec()));
1057                    secrets.len()
1058                }
1059            }
1060        };
1061        let mut shapes: Vec<String> = rules
1062            .iter()
1063            .filter(|rule| {
1064                matches!(rule.path.first(), Some(PathElement::Attribute(name)) if name == field)
1065            })
1066            .map(|rule| match &rule.action {
1067                config::ValidatedAction::Fake {
1068                    generator,
1069                    seed: Some(seed),
1070                } => {
1071                    let n = secret_number(seed.as_bytes());
1072                    format!("seeded fake '{generator}' (seed {n})")
1073                }
1074                config::ValidatedAction::Fake {
1075                    generator,
1076                    seed: None,
1077                } => format!("unseeded fake '{generator}'"),
1078                config::ValidatedAction::Hash { salt } => {
1079                    let n = secret_number(salt.as_bytes());
1080                    format!("hash (salt {n})")
1081                }
1082                other => format!("{other:?}")
1083                    .split_whitespace()
1084                    .next()
1085                    .unwrap_or("action")
1086                    .to_lowercase(),
1087            })
1088            .collect();
1089        shapes.sort();
1090        shapes.dedup();
1091        if shapes.len() > 1 {
1092            messages.push(format!(
1093                "'{field}' is in [consistency] fields but its rules do not agree on how they \
1094                 generate ({}). A seeded fake derives its value and does not use the consistency \
1095                 map, so mixing it with anything else can give one input two different values",
1096                shapes.join(", ")
1097            ));
1098        }
1099    }
1100    messages.sort();
1101    messages
1102}
1103
1104/// Refuse a rule whose `tables` names a table the schema file does not have.
1105///
1106/// The schema file is every table the run knows about, so a scoped name
1107/// absent from it is a misspelling rather than a table `--tables` left out.
1108/// Left to the end of the run it would be filed as "not applied", which
1109/// reads like a table left out on purpose, and the attribute the rule names
1110/// would reach the output untouched on a run that exits 0.
1111fn refuse_scopes_outside_the_schema(
1112    rules: &[config::ValidatedRule],
1113    schemas: &[schema::TableSchema],
1114) -> Result<(), ImportError> {
1115    for (index, rule) in rules.iter().enumerate() {
1116        let Some(tables) = rule.tables.as_ref() else {
1117            continue;
1118        };
1119        let missing: Vec<String> = tables
1120            .iter()
1121            .filter(|name| !schemas.iter().any(|s| &s.table_name == *name))
1122            .cloned()
1123            .collect();
1124        if missing.is_empty() {
1125            continue;
1126        }
1127        let known: Vec<String> = schemas.iter().map(|s| s.table_name.clone()).collect();
1128        return Err(ImportError::Config(format!(
1129            "rule {} (path '{}') is scoped to {}, and the schema file has no such {}. Its \
1130             tables are {}",
1131            index + 1,
1132            crate::expressions::format_path_for_error(&rule.path),
1133            quoted_names(&missing),
1134            if missing.len() > 1 { "tables" } else { "table" },
1135            quoted_names(&known)
1136        )));
1137    }
1138    Ok(())
1139}
1140
1141/// `'a'`, or `'a' and 'b'`, or `'a', 'b' and 'c'`, for a message that names
1142/// tables, indexes or attributes. One helper for the whole importer, so every
1143/// message lists names the same way.
1144pub(crate) fn quoted_names<I, S>(names: I) -> String
1145where
1146    I: IntoIterator<Item = S>,
1147    S: AsRef<str>,
1148{
1149    let quoted: Vec<String> = names
1150        .into_iter()
1151        .map(|n| format!("'{}'", n.as_ref()))
1152        .collect();
1153    match quoted.split_last() {
1154        Some((last, [])) => last.clone(),
1155        Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
1156        None => String::new(),
1157    }
1158}
1159
1160/// Puts the database's ordinary PRAGMAs back however the import leaves.
1161struct BulkLoading<'a> {
1162    db: &'a Database,
1163}
1164
1165impl Drop for BulkLoading<'_> {
1166    fn drop(&mut self) {
1167        let _ = self.db.disable_bulk_loading();
1168    }
1169}
1170
1171/// The table's own key attributes.
1172///
1173/// The primary key only. Both callers are about one row replacing another, and
1174/// only the primary key can do that: rewriting an index key moves a row within
1175/// that index without losing it. Widening this to index keys made the
1176/// write-path choice and the collision warning describe a loss that cannot
1177/// happen. [`index_key_attrs`] carries the index keys for the callers that
1178/// want those instead.
1179fn extract_key_attrs(request: &crate::actions::create_table::CreateTableRequest) -> Vec<String> {
1180    request
1181        .key_schema
1182        .iter()
1183        .map(|ks| ks.attribute_name.clone())
1184        .collect()
1185}
1186
1187/// Key attributes belonging to an index rather than the table.
1188///
1189/// Rewriting one of these does not lose a row, but it does move the row within
1190/// that index, so a query over it stops finding what it found.
1191fn index_key_attrs(request: &crate::actions::create_table::CreateTableRequest) -> Vec<String> {
1192    let base = extract_key_attrs(request);
1193    let mut names = Vec::new();
1194    let mut push = |name: &String| {
1195        if !base.contains(name) && !names.contains(name) {
1196            names.push(name.clone());
1197        }
1198    };
1199    for gsi in request.global_secondary_indexes.as_deref().unwrap_or(&[]) {
1200        for ks in &gsi.key_schema {
1201            push(&ks.attribute_name);
1202        }
1203    }
1204    for lsi in request.local_secondary_indexes.as_deref().unwrap_or(&[]) {
1205        for ks in &lsi.key_schema {
1206            push(&ks.attribute_name);
1207        }
1208    }
1209    names
1210}
1211
1212/// Rename a finished temp file onto the output path.
1213///
1214/// Without `--force` the rename refuses an existing file rather than
1215/// replacing it. The existence check at the start of the run is not enough
1216/// on its own: an import takes time, and a second run writing the same path
1217/// can finish inside that window, after the check and before this rename.
1218fn move_into_place(tmp: tempfile::TempPath, dst: &Path, force: bool) -> Result<(), ImportError> {
1219    let moved = if force {
1220        tmp.persist(dst)
1221    } else {
1222        tmp.persist_noclobber(dst)
1223    };
1224    moved.map_err(|e| {
1225        ImportError::Database(format!(
1226            "Failed to move database to output path '{}': {}",
1227            dst.display(),
1228            e.error
1229        ))
1230    })
1231}
1232
1233/// Compress `src` into `dst` with zstd, returning the compressed size.
1234fn compress_to(src: &Path, dst: &Path) -> Result<usize, ImportError> {
1235    let input = std::fs::File::open(src).map_err(|e| {
1236        ImportError::Database(format!(
1237            "Failed to open {} for compression: {e}",
1238            src.display()
1239        ))
1240    })?;
1241    let output = std::fs::File::create(dst)
1242        .map_err(|e| ImportError::Database(format!("Failed to create {}: {e}", dst.display())))?;
1243    let mut encoder = zstd::Encoder::new(output, 3)
1244        .map_err(|e| ImportError::Database(format!("Failed to create zstd encoder: {e}")))?;
1245    std::io::copy(&mut std::io::BufReader::new(input), &mut encoder)
1246        .map_err(|e| ImportError::Database(format!("Compression failed: {e}")))?;
1247    encoder
1248        .finish()
1249        .map_err(|e| ImportError::Database(format!("Failed to finalise compression: {e}")))?;
1250    Ok(std::fs::metadata(dst)
1251        .map(|m| m.len() as usize)
1252        .unwrap_or(0))
1253}
1254/// Format bytes as human-readable.
1255fn format_bytes(bytes: usize) -> String {
1256    if bytes < 1024 {
1257        format!("{bytes} B")
1258    } else if bytes < 1024 * 1024 {
1259        format!("{:.1} KB", bytes as f64 / 1024.0)
1260    } else {
1261        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
1262    }
1263}
1264
1265#[cfg(all(test, feature = "import"))]
1266mod tests {
1267    use super::*;
1268
1269    #[test]
1270    fn the_text_views_are_the_notices_in_order_and_the_exposures_among_them() {
1271        let mut summary = ImportSummary {
1272            tables: Vec::new(),
1273            total_items: 0,
1274            total_bytes: 0,
1275            total_skipped: 0,
1276            notices: Vec::new(),
1277            warnings: vec!["stale".to_string()],
1278            exposures: vec!["stale".to_string()],
1279            output_path: None,
1280        };
1281        summary.notice(Notice::caution("first, a caution"));
1282        summary.notice(Notice::exposure("then an exposure"));
1283        summary.notice(Notice::caution("last, a caution"));
1284        summary.finish();
1285
1286        assert_eq!(
1287            summary.warnings,
1288            vec!["first, a caution", "then an exposure", "last, a caution"]
1289        );
1290        assert_eq!(summary.exposures, vec!["then an exposure"]);
1291    }
1292
1293    #[test]
1294    fn a_file_that_appeared_after_the_check_is_not_replaced_without_force() {
1295        // The race the up-front check cannot close: something else wrote the
1296        // destination while this run was importing.
1297        let dir = tempfile::tempdir().unwrap();
1298        let dst = dir.path().join("out.db");
1299        std::fs::write(&dst, b"theirs").unwrap();
1300
1301        let tmp = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
1302        std::fs::write(tmp.path(), b"ours").unwrap();
1303        let err = move_into_place(tmp.into_temp_path(), &dst, false).unwrap_err();
1304        assert!(err.to_string().contains("out.db"), "{err}");
1305        assert_eq!(std::fs::read(&dst).unwrap(), b"theirs", "theirs survives");
1306
1307        let tmp = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
1308        std::fs::write(tmp.path(), b"ours").unwrap();
1309        move_into_place(tmp.into_temp_path(), &dst, true).unwrap();
1310        assert_eq!(std::fs::read(&dst).unwrap(), b"ours", "--force replaces it");
1311    }
1312}