1pub(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#[derive(Debug)]
35pub enum ImportError {
36 Config(String),
38 Data(String),
40 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
62pub struct ImportCommand {
64 pub source: std::path::PathBuf,
66 pub output: Option<std::path::PathBuf>,
68 pub schema: std::path::PathBuf,
70 pub rules: Option<std::path::PathBuf>,
72 pub data_model: Option<std::path::PathBuf>,
76 pub tables: Option<Vec<String>>,
78 pub compress: bool,
80 pub force: bool,
82 pub continue_on_error: bool,
86}
87
88#[derive(Debug)]
90pub struct ImportSummary {
91 pub tables: Vec<TableImportResult>,
93 pub total_items: usize,
95 pub total_bytes: usize,
97 pub total_skipped: usize,
99 pub notices: Vec<Notice>,
101 pub warnings: Vec<String>,
103 pub exposures: Vec<String>,
107 pub output_path: Option<std::path::PathBuf>,
109}
110
111impl ImportSummary {
112 fn notice(&mut self, notice: Notice) {
115 self.notices.push(notice);
116 }
117
118 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#[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
139pub 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(_)) => {} Err(e) => return Err(ImportError::Database(e.to_string())),
156 }
157 }
158 Ok(created)
159}
160
161fn 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
179pub fn run_into(db: &Database, cmd: ImportCommand) -> Result<ImportSummary, ImportError> {
186 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 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 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 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 let schema_map: std::collections::HashMap<&str, &schema::TableSchema> =
260 schemas.iter().map(|s| (s.table_name.as_str(), s)).collect();
261
262 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 db.enable_bulk_loading()
284 .map_err(|e| format!("Failed to enable bulk loading: {e}"))?;
285 let _restore_pragmas = BulkLoading { db };
290
291 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 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 db.disable_bulk_loading()
344 .map_err(|e| format!("Failed to disable bulk loading: {e}"))?;
345
346 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
359fn 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
377fn 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 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 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
436struct 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 rebuilds_keys: bool,
450 continue_on_error: bool,
451 consistency_map: ConsistencyMap,
452 seen_warnings: HashSet<String>,
453 rule_work: Vec<anonymise::RuleWork>,
457}
458
459impl TableImport<'_> {
460 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 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 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 if batch_error.is_some() {
529 return;
530 }
531
532 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 (Some(deriver), None) => {
568 deriver.note_primary_key(&item, &mut pending.observed)
569 }
570 (None, _) => {}
571 }
572 for notice in warnings {
573 pending.raise(notice.for_table(table_name), &self.seen_warnings);
578 }
579 }
580 pending.items.push(item);
581
582 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 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 for warning in stats.warnings {
611 summary.notice(Notice::caution(warning));
612 }
613
614 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 for notice in deriver.take_notices() {
635 summary.notice(notice.for_table(table_name));
636 }
637
638 let join_breaks = deriver.join_breaks();
645 if let Some(first) = join_breaks.first() {
646 for n in &summary.notices {
650 eprintln!(" - {n}");
651 }
652 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 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
737struct Pending {
745 items: Vec<crate::types::Item>,
746 observed: keys::Batch,
748 notices: Vec<Notice>,
750 raised: HashSet<String>,
753 mask_passthroughs: std::collections::HashMap<String, usize>,
755 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 fn raise(&mut self, notice: Notice, already_raised: &HashSet<String>) {
776 if already_raised.contains(¬ice.message) {
777 return;
778 }
779 if self.raised.insert(notice.message.clone()) {
780 self.notices.push(notice);
781 }
782 }
783}
784
785struct Landed {
787 key_deriver: Option<keys::KeyDeriver>,
788 mask_passthroughs: std::collections::HashMap<String, usize>,
791 items: usize,
792 bytes: usize,
793}
794
795fn 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
814fn 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 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 }
868}
869
870pub 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 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 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 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 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 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
963fn 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 let mut table = table.clone();
979 unwrap_describe_table_shapes(&mut table);
980 return Some(table);
981 }
982 }
983 None
984}
985
986pub(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
1031fn 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 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
1104fn 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
1141pub(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
1160struct 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
1171fn 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
1187fn 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
1212fn 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
1233fn 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}
1254fn 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 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}