create-feltdb 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,10 +135,18 @@ pub enum Phase {
135
135
  /// AuditWrite` is therefore routing plus decode plus encode, measured as a
136
136
  /// difference of two spans rather than asserted.
137
137
  HandlerBody,
138
+ /// Resolving a managed request's application contract: the revision, the
139
+ /// runtime inventory it is validated against, and the resolved runtime.
140
+ /// Every `/v1` request pays it, so it must not depend on unrelated state.
141
+ ContractResolution,
142
+ /// Cloning one state namespace's rows for a state-contract read or
143
+ /// transaction. Proportional to that application's stored rows, not to
144
+ /// other applications'.
145
+ NamespaceSnapshot,
138
146
  }
139
147
 
140
148
  impl Phase {
141
- pub const ALL: [Phase; 25] = [
149
+ pub const ALL: [Phase; 27] = [
142
150
  Phase::StateLockWait,
143
151
  Phase::StateLockHold,
144
152
  Phase::IndexedQuery,
@@ -164,6 +172,8 @@ impl Phase {
164
172
  Phase::AuditGroupSync,
165
173
  Phase::AuditClose,
166
174
  Phase::HandlerBody,
175
+ Phase::ContractResolution,
176
+ Phase::NamespaceSnapshot,
167
177
  ];
168
178
 
169
179
  /// The stable name this phase is reported under.
@@ -194,6 +204,8 @@ impl Phase {
194
204
  Phase::AuditGroupSync => "audit_group_sync",
195
205
  Phase::AuditClose => "audit_close",
196
206
  Phase::HandlerBody => "handler_body",
207
+ Phase::ContractResolution => "contract_resolution",
208
+ Phase::NamespaceSnapshot => "namespace_snapshot",
197
209
  }
198
210
  }
199
211
 
@@ -210,9 +222,11 @@ impl Phase {
210
222
  // The predicate runs on both executions, so its parent is the lock
211
223
  // rather than either query phase.
212
224
  Phase::PredicateEvaluation => Some(Phase::StateLockHold),
213
- Phase::Authorization | Phase::AuditWrite | Phase::HandlerBody => {
214
- Some(Phase::HttpHandler)
215
- }
225
+ Phase::Authorization
226
+ | Phase::AuditWrite
227
+ | Phase::HandlerBody
228
+ | Phase::ContractResolution
229
+ | Phase::NamespaceSnapshot => Some(Phase::HttpHandler),
216
230
  Phase::Ordering | Phase::ResultMaterialization => Some(Phase::HandlerBody),
217
231
  Phase::RecordMutation | Phase::IndexMaintenance | Phase::Persistence => {
218
232
  Some(Phase::StateLockHold)
@@ -4,11 +4,32 @@ use std::{
4
4
  fs::{self, File},
5
5
  io::{self, Read, Write},
6
6
  path::{Path, PathBuf},
7
- time::{SystemTime, UNIX_EPOCH},
7
+ time::{Instant, SystemTime, UNIX_EPOCH},
8
8
  };
9
9
 
10
10
  pub const BACKUP_FORMAT: u32 = 1;
11
11
 
12
+ /// Where the backed-up state came from. Optional in the manifest so that
13
+ /// bundles written before provenance was recorded still verify: the format is
14
+ /// unchanged, and an absent field is reported as unknown rather than invented.
15
+ #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
16
+ pub struct BackupSource {
17
+ /// Absolute path of the durable state file the bundle was taken from.
18
+ #[serde(default, skip_serializing_if = "Option::is_none")]
19
+ pub data: Option<String>,
20
+ /// SHA-256 of the authoritative state artifact (`state.log`, or the
21
+ /// consistent `database.snapshot.json` of an online backup).
22
+ #[serde(default, skip_serializing_if = "Option::is_none")]
23
+ pub state_sha256: Option<String>,
24
+ /// The `feltdb-server` crate version that wrote the bundle (not the npm
25
+ /// package version).
26
+ #[serde(default, skip_serializing_if = "Option::is_none")]
27
+ pub server_version: Option<String>,
28
+ /// The durable format version of the build that wrote the bundle.
29
+ #[serde(default, skip_serializing_if = "Option::is_none")]
30
+ pub durable_format: Option<u32>,
31
+ }
32
+
12
33
  #[derive(Clone, Debug, Serialize, Deserialize)]
13
34
  pub struct BackupEntry {
14
35
  pub path: String,
@@ -21,6 +42,93 @@ pub struct BackupManifest {
21
42
  pub format: u32,
22
43
  pub created_ms: u64,
23
44
  pub entries: Vec<BackupEntry>,
45
+ #[serde(default, skip_serializing_if = "Option::is_none")]
46
+ pub source: Option<BackupSource>,
47
+ }
48
+
49
+ /// The explicit outcome of a backup operation.
50
+ ///
51
+ /// `status` is `ok` only when the bundle was written *and* independently
52
+ /// verified; the CLI exit code follows it, so a successful backup is never
53
+ /// reported as a failure and an unverifiable one is never reported as valid.
54
+ #[derive(Clone, Debug, Serialize, Deserialize)]
55
+ pub struct BackupReport {
56
+ pub operation: String,
57
+ pub status: String,
58
+ /// Absolute path of the bundle.
59
+ pub path: String,
60
+ /// SHA-256 of the bundle manifest: the bundle's stable identity.
61
+ pub backup_id: String,
62
+ pub source: BackupSource,
63
+ pub format: u32,
64
+ pub created_ms: u64,
65
+ pub files: usize,
66
+ pub bytes_written: u64,
67
+ /// `verified` or `failed`.
68
+ pub verification: String,
69
+ #[serde(skip_serializing_if = "Option::is_none")]
70
+ pub error: Option<String>,
71
+ pub duration_ms: u64,
72
+ }
73
+
74
+ /// Resolve a user-supplied path against the working directory.
75
+ ///
76
+ /// A bare relative path such as `backup` has an *empty* parent, not `.`; every
77
+ /// operation that syncs or stages next to the output therefore works on the
78
+ /// absolute form, which is what makes `backup`, `./backup` and
79
+ /// `/abs/path/backup` behave identically.
80
+ pub fn normalize_path(path: &Path) -> io::Result<PathBuf> {
81
+ if path.as_os_str().is_empty() {
82
+ return Err(io::Error::new(
83
+ io::ErrorKind::InvalidInput,
84
+ "backup path must not be empty",
85
+ ));
86
+ }
87
+ std::path::absolute(path)
88
+ }
89
+
90
+ /// SHA-256 of a bundle's manifest bytes, used as the bundle identity.
91
+ pub fn backup_id(archive: &Path) -> io::Result<String> {
92
+ Ok(format!(
93
+ "{:x}",
94
+ Sha256::digest(fs::read(archive.join("manifest.json"))?)
95
+ ))
96
+ }
97
+
98
+ /// Verify a bundle and describe it as a report, without ever turning a
99
+ /// verification failure into success.
100
+ pub fn verification_report(archive: &Path, started: Instant, operation: &str) -> BackupReport {
101
+ let path = normalize_path(archive).unwrap_or_else(|_| archive.to_path_buf());
102
+ match verify_bundle(&path).and_then(|manifest| Ok((backup_id(&path)?, manifest))) {
103
+ Ok((id, manifest)) => BackupReport {
104
+ operation: operation.into(),
105
+ status: "ok".into(),
106
+ path: path.display().to_string(),
107
+ backup_id: id,
108
+ source: manifest.source.clone().unwrap_or_default(),
109
+ format: manifest.format,
110
+ created_ms: manifest.created_ms,
111
+ files: manifest.entries.len(),
112
+ bytes_written: manifest.entries.iter().map(|entry| entry.bytes).sum(),
113
+ verification: "verified".into(),
114
+ error: None,
115
+ duration_ms: started.elapsed().as_millis() as u64,
116
+ },
117
+ Err(error) => BackupReport {
118
+ operation: operation.into(),
119
+ status: "failed".into(),
120
+ path: path.display().to_string(),
121
+ backup_id: backup_id(&path).unwrap_or_default(),
122
+ source: BackupSource::default(),
123
+ format: 0,
124
+ created_ms: 0,
125
+ files: 0,
126
+ bytes_written: 0,
127
+ verification: "failed".into(),
128
+ error: Some(error.to_string()),
129
+ duration_ms: started.elapsed().as_millis() as u64,
130
+ },
131
+ }
24
132
  }
25
133
 
26
134
  pub fn server_artifacts(data: &Path, keys: &Path, audit: &Path) -> Vec<(String, PathBuf)> {
@@ -72,6 +180,20 @@ pub fn create_bundle_with_virtual(
72
180
  artifacts: &[(String, PathBuf)],
73
181
  virtual_files: &[(String, Vec<u8>)],
74
182
  ) -> io::Result<BackupManifest> {
183
+ create_bundle_with_source(output, artifacts, virtual_files, BackupSource::default())
184
+ }
185
+
186
+ /// Write a bundle and record where its state came from.
187
+ ///
188
+ /// The bundle is staged beside the output and published with one rename, so a
189
+ /// reader sees either no bundle or a complete one.
190
+ pub fn create_bundle_with_source(
191
+ output: &Path,
192
+ artifacts: &[(String, PathBuf)],
193
+ virtual_files: &[(String, Vec<u8>)],
194
+ mut source: BackupSource,
195
+ ) -> io::Result<BackupManifest> {
196
+ let output = &normalize_path(output)?;
75
197
  if output.exists() {
76
198
  return Err(io::Error::new(
77
199
  io::ErrorKind::AlreadyExists,
@@ -114,6 +236,14 @@ pub fn create_bundle_with_virtual(
114
236
  });
115
237
  }
116
238
  entries.sort_by(|left, right| left.path.cmp(&right.path));
239
+ if source.state_sha256.is_none() {
240
+ source.state_sha256 = entries
241
+ .iter()
242
+ .find(|entry| {
243
+ entry.path == "files/database.snapshot.json" || entry.path == "files/state.log"
244
+ })
245
+ .map(|entry| entry.sha256.clone());
246
+ }
117
247
  let manifest = BackupManifest {
118
248
  format: BACKUP_FORMAT,
119
249
  created_ms: SystemTime::now()
@@ -121,6 +251,7 @@ pub fn create_bundle_with_virtual(
121
251
  .unwrap_or_default()
122
252
  .as_millis() as u64,
123
253
  entries,
254
+ source: Some(source),
124
255
  };
125
256
  write_synced(
126
257
  &staging.join("manifest.json"),
@@ -128,9 +259,7 @@ pub fn create_bundle_with_virtual(
128
259
  )?;
129
260
  sync_directory(&staging)?;
130
261
  fs::rename(&staging, output)?;
131
- if let Some(parent) = output.parent() {
132
- sync_directory(parent)?;
133
- }
262
+ sync_parent(output)?;
134
263
  Ok(manifest)
135
264
  })();
136
265
  if result.is_err() {
@@ -140,6 +269,7 @@ pub fn create_bundle_with_virtual(
140
269
  }
141
270
 
142
271
  pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
272
+ let archive = &normalize_path(archive)?;
143
273
  if archive.with_extension("partial").exists() {
144
274
  return Err(io::Error::new(
145
275
  io::ErrorKind::InvalidData,
@@ -167,6 +297,25 @@ pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
167
297
  ));
168
298
  }
169
299
  }
300
+ // Completeness runs both ways: every manifest entry is present and intact
301
+ // (above), and nothing unrecorded sits in the bundle. An extra file would be
302
+ // restored as state that no checksum ever covered.
303
+ let recorded: std::collections::BTreeSet<&str> = manifest
304
+ .entries
305
+ .iter()
306
+ .map(|entry| entry.path.as_str())
307
+ .collect();
308
+ let mut present = Vec::new();
309
+ collect_files(&archive.join("files"), "files", &mut present)?;
310
+ if let Some(extra) = present
311
+ .iter()
312
+ .find(|path| !recorded.contains(path.as_str()))
313
+ {
314
+ return Err(io::Error::new(
315
+ io::ErrorKind::InvalidData,
316
+ format!("backup contains a file absent from its manifest: {extra}"),
317
+ ));
318
+ }
170
319
  Ok(manifest)
171
320
  }
172
321
 
@@ -174,6 +323,7 @@ pub fn verify_bundle(archive: &Path) -> io::Result<BackupManifest> {
174
323
  /// does not exceed `at_ms`. Every manifest-bearing child is verified first so a
175
324
  /// corrupt recent backup cannot cause a silent fallback to older data.
176
325
  pub fn select_bundle_at(catalog: &Path, at_ms: u64) -> io::Result<PathBuf> {
326
+ let catalog = &normalize_path(catalog)?;
177
327
  let mut candidates = Vec::new();
178
328
  for entry in fs::read_dir(catalog)? {
179
329
  let path = entry?.path();
@@ -198,6 +348,8 @@ pub fn select_bundle_at(catalog: &Path, at_ms: u64) -> io::Result<PathBuf> {
198
348
  }
199
349
 
200
350
  pub fn restore_bundle(archive: &Path, output: &Path) -> io::Result<BackupManifest> {
351
+ let archive = &normalize_path(archive)?;
352
+ let output = &normalize_path(output)?;
201
353
  let manifest = verify_bundle(archive)?;
202
354
  if output.exists() {
203
355
  return Err(io::Error::new(
@@ -221,9 +373,7 @@ pub fn restore_bundle(archive: &Path, output: &Path) -> io::Result<BackupManifes
221
373
  )?;
222
374
  sync_directory(&staging)?;
223
375
  fs::rename(&staging, output)?;
224
- if let Some(parent) = output.parent() {
225
- sync_directory(parent)?;
226
- }
376
+ sync_parent(output)?;
227
377
  Ok(manifest.clone())
228
378
  })();
229
379
  if result.is_err() {
@@ -271,6 +421,22 @@ fn copy_source(
271
421
  }
272
422
  Ok(())
273
423
  }
424
+ fn collect_files(directory: &Path, relative: &str, found: &mut Vec<String>) -> io::Result<()> {
425
+ if !directory.exists() {
426
+ return Ok(());
427
+ }
428
+ for child in fs::read_dir(directory)? {
429
+ let child = child?;
430
+ let name = format!("{relative}/{}", child.file_name().to_string_lossy());
431
+ let kind = child.file_type()?;
432
+ if kind.is_dir() {
433
+ collect_files(&child.path(), &name, found)?;
434
+ } else {
435
+ found.push(name);
436
+ }
437
+ }
438
+ Ok(())
439
+ }
274
440
  fn copy_tree(source: &Path, destination: &Path) -> io::Result<()> {
275
441
  if !source.exists() {
276
442
  return Ok(());
@@ -322,6 +488,14 @@ fn sync_file(path: &Path) -> io::Result<()> {
322
488
  fn sync_directory(path: &Path) -> io::Result<()> {
323
489
  File::open(path)?.sync_all()
324
490
  }
491
+ /// Sync the directory that holds `path`. A relative path with no directory
492
+ /// component lives in the working directory; its parent is `.`, never `""`.
493
+ fn sync_parent(path: &Path) -> io::Result<()> {
494
+ match path.parent() {
495
+ Some(parent) if !parent.as_os_str().is_empty() => sync_directory(parent),
496
+ _ => sync_directory(Path::new(".")),
497
+ }
498
+ }
325
499
 
326
500
  #[cfg(test)]
327
501
  mod tests {
@@ -409,4 +583,113 @@ mod tests {
409
583
  assert!(select_bundle_at(&root.join("catalog"), first.created_ms - 1).is_err());
410
584
  fs::remove_dir_all(root).unwrap();
411
585
  }
586
+
587
+ #[test]
588
+ fn relative_and_absolute_paths_normalize_identically() {
589
+ let cwd = std::env::current_dir().unwrap();
590
+ assert_eq!(
591
+ normalize_path(Path::new("backup")).unwrap(),
592
+ cwd.join("backup")
593
+ );
594
+ assert_eq!(
595
+ normalize_path(Path::new("./backup")).unwrap(),
596
+ cwd.join("./backup")
597
+ );
598
+ assert_eq!(
599
+ normalize_path(&cwd.join("backup")).unwrap(),
600
+ cwd.join("backup")
601
+ );
602
+ assert!(normalize_path(Path::new("")).is_err());
603
+ // The defect: a bare name has an empty parent, which cannot be opened
604
+ // for the post-rename directory sync. `sync_parent` must treat it as `.`.
605
+ assert_eq!(Path::new("backup").parent(), Some(Path::new("")));
606
+ sync_parent(Path::new("backup")).unwrap();
607
+ }
608
+
609
+ #[test]
610
+ fn backup_into_missing_parent_records_provenance_and_verifies() {
611
+ let root = std::env::temp_dir().join(format!(
612
+ "feltdb-provenance-{}-{}",
613
+ std::process::id(),
614
+ rand::random::<u64>()
615
+ ));
616
+ fs::create_dir_all(&root).unwrap();
617
+ fs::write(root.join("state.log"), b"state").unwrap();
618
+ let archive = root.join("does/not/exist/yet/archive");
619
+ let manifest = create_bundle_with_source(
620
+ &archive,
621
+ &[("state.log".into(), root.join("state.log"))],
622
+ &[],
623
+ BackupSource {
624
+ data: Some("/data/state.log".into()),
625
+ ..BackupSource::default()
626
+ },
627
+ )
628
+ .unwrap();
629
+ let source = manifest.source.unwrap();
630
+ assert_eq!(source.data.as_deref(), Some("/data/state.log"));
631
+ assert_eq!(
632
+ source.state_sha256.as_deref(),
633
+ Some(manifest.entries[0].sha256.as_str())
634
+ );
635
+ let report = verification_report(&archive, Instant::now(), "verify");
636
+ assert_eq!(report.status, "ok");
637
+ assert_eq!(report.verification, "verified");
638
+ assert_eq!(report.bytes_written, 5);
639
+ assert_eq!(report.backup_id, backup_id(&archive).unwrap());
640
+ fs::remove_dir_all(root).unwrap();
641
+ }
642
+
643
+ #[test]
644
+ fn unrecorded_files_and_missing_manifests_fail_verification() {
645
+ let root = std::env::temp_dir().join(format!(
646
+ "feltdb-completeness-{}-{}",
647
+ std::process::id(),
648
+ rand::random::<u64>()
649
+ ));
650
+ fs::create_dir_all(&root).unwrap();
651
+ fs::write(root.join("state.log"), b"state").unwrap();
652
+ let archive = root.join("archive");
653
+ create_bundle(&archive, &[("state.log".into(), root.join("state.log"))]).unwrap();
654
+ fs::write(archive.join("files/injected"), b"unrecorded").unwrap();
655
+ let report = verification_report(&archive, Instant::now(), "verify");
656
+ assert_eq!(report.status, "failed");
657
+ assert!(report.error.unwrap().contains("absent from its manifest"));
658
+ fs::remove_file(archive.join("files/injected")).unwrap();
659
+ fs::remove_file(archive.join("files/state.log")).unwrap();
660
+ assert_eq!(
661
+ verification_report(&archive, Instant::now(), "verify").status,
662
+ "failed"
663
+ );
664
+ fs::remove_file(archive.join("manifest.json")).unwrap();
665
+ assert_eq!(
666
+ verification_report(&archive, Instant::now(), "verify").verification,
667
+ "failed"
668
+ );
669
+ fs::remove_dir_all(root).unwrap();
670
+ }
671
+
672
+ #[test]
673
+ fn manifests_written_before_provenance_still_verify() {
674
+ let root = std::env::temp_dir().join(format!(
675
+ "feltdb-legacy-manifest-{}-{}",
676
+ std::process::id(),
677
+ rand::random::<u64>()
678
+ ));
679
+ fs::create_dir_all(root.join("archive/files")).unwrap();
680
+ fs::write(root.join("archive/files/state.log"), b"legacy").unwrap();
681
+ let digest = format!("{:x}", Sha256::digest(b"legacy"));
682
+ fs::write(
683
+ root.join("archive/manifest.json"),
684
+ format!(r#"{{"format":1,"created_ms":1,"entries":[{{"path":"files/state.log","bytes":6,"sha256":"{digest}"}}]}}"#),
685
+ )
686
+ .unwrap();
687
+ let manifest = verify_bundle(&root.join("archive")).unwrap();
688
+ assert!(manifest.source.is_none());
689
+ assert_eq!(
690
+ verification_report(&root.join("archive"), Instant::now(), "verify").source,
691
+ BackupSource::default()
692
+ );
693
+ fs::remove_dir_all(root).unwrap();
694
+ }
412
695
  }