Persist immutable private send recovery records with guarded transitions

This commit is contained in:
archipelago
2026-10-06 16:29:34 -04:00
parent a4ede20204
commit d4b359dbae
5 changed files with 707 additions and 70 deletions
+95 -64
View File
@@ -82,6 +82,100 @@ impl std::fmt::Debug for PreparedSwap {
}
}
impl PreparedSwap {
pub(super) fn validate_for_mint(&self, mint_url: &str) -> Result<()> {
anyhow::ensure!(
self.mint_url == mint_url,
"Prepared swap belongs to a different mint"
);
anyhow::ensure!(
!self.inputs.is_empty()
&& !self.outputs.is_empty()
&& self.outputs.len() == self.blinding.len(),
"Invalid prepared swap structure"
);
let commitments: std::collections::HashSet<_> = self
.outputs
.iter()
.map(|output| output.b_prime.as_str())
.collect();
anyhow::ensure!(
commitments.len() == self.outputs.len(),
"Prepared swap contains duplicate outputs"
);
let input_secrets: std::collections::HashSet<_> = self
.inputs
.iter()
.map(|proof| proof.secret.as_str())
.collect();
anyhow::ensure!(
input_secrets.len() == self.inputs.len(),
"Prepared swap contains duplicate inputs"
);
anyhow::ensure!(
self.keyset.unit == "sat",
"Prepared swap is not denominated in sats"
);
let input_total = self
.inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input amount overflow")?;
let output_total = self
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output amount overflow")?;
anyhow::ensure!(
output_total <= input_total,
"Prepared swap output value exceeds its inputs"
);
for (output, secret) in self.outputs.iter().zip(&self.blinding) {
anyhow::ensure!(
output.id == self.keyset.id
&& output.amount == secret.amount
&& output.amount.is_power_of_two(),
"Prepared swap output metadata changed"
);
let factor = secp256k1::SecretKey::from_slice(&secret.factor)
.context("Invalid prepared blinding factor")?;
let blinded = bdhke::blind_message(&secret.secret, &factor)?;
anyhow::ensure!(
output.b_prime == hex::encode(blinded.b_prime.serialize()),
"Prepared swap output commitment changed"
);
std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?;
self.keyset.key_for_amount(output.amount)?;
}
Ok(())
}
pub(super) fn validate_result_proofs(&self, proofs: &[Proof]) -> Result<()> {
anyhow::ensure!(
proofs.len() == self.blinding.len(),
"Payment result has missing or extra proofs"
);
let mut expected: std::collections::HashMap<_, _> = self
.blinding
.iter()
.map(|output| Ok((std::str::from_utf8(&output.secret)?, output.amount)))
.collect::<Result<_>>()?;
for proof in proofs {
anyhow::ensure!(
proof.id == self.keyset.id
&& expected.remove(proof.secret.as_str()) == Some(proof.amount),
"Payment result does not match prepared outputs"
);
proof.c_as_pubkey()?;
}
anyhow::ensure!(
expected.is_empty(),
"Payment result omitted prepared outputs"
);
Ok(())
}
}
/// Result of a mint operation.
pub struct MintResult {
pub proofs: Vec<Proof>,
@@ -656,70 +750,7 @@ impl MintClient {
}
fn validate_prepared_swap(&self, prepared: &PreparedSwap) -> Result<()> {
anyhow::ensure!(
prepared.mint_url == self.url,
"Prepared swap belongs to a different mint"
);
anyhow::ensure!(
!prepared.inputs.is_empty()
&& !prepared.outputs.is_empty()
&& prepared.outputs.len() == prepared.blinding.len(),
"Invalid prepared swap structure"
);
let commitments: std::collections::HashSet<_> = prepared
.outputs
.iter()
.map(|output| output.b_prime.as_str())
.collect();
anyhow::ensure!(
commitments.len() == prepared.outputs.len(),
"Prepared swap contains duplicate outputs"
);
let input_secrets: std::collections::HashSet<_> = prepared
.inputs
.iter()
.map(|proof| proof.secret.as_str())
.collect();
anyhow::ensure!(
input_secrets.len() == prepared.inputs.len(),
"Prepared swap contains duplicate inputs"
);
anyhow::ensure!(
prepared.keyset.unit == "sat",
"Prepared swap is not denominated in sats"
);
let input_total = prepared
.inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input amount overflow")?;
let output_total = prepared
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output amount overflow")?;
anyhow::ensure!(
output_total <= input_total,
"Prepared swap output value exceeds its inputs"
);
for (output, secret) in prepared.outputs.iter().zip(&prepared.blinding) {
anyhow::ensure!(
output.id == prepared.keyset.id
&& output.amount == secret.amount
&& output.amount.is_power_of_two(),
"Prepared swap output metadata changed"
);
let factor = secp256k1::SecretKey::from_slice(&secret.factor)
.context("Invalid prepared blinding factor")?;
let blinded = bdhke::blind_message(&secret.secret, &factor)?;
anyhow::ensure!(
output.b_prime == hex::encode(blinded.b_prime.serialize()),
"Prepared swap output commitment changed"
);
std::str::from_utf8(&secret.secret).context("Invalid prepared secret encoding")?;
prepared.keyset.key_for_amount(output.amount)?;
}
Ok(())
prepared.validate_for_mint(&self.url)
}
/// Execute only an already prepared request. Callers implementing recovery
+1
View File
@@ -11,3 +11,4 @@ pub mod mint_client;
mod mutation;
pub mod nut13;
pub mod profits;
mod send_journal;
+20 -6
View File
@@ -10,7 +10,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard};
type Registry = HashMap<PathBuf, Weak<Mutex<()>>>;
static LOCKS: OnceLock<RegistryMutex<Registry>> = OnceLock::new();
async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> {
async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
tokio::fs::create_dir_all(data_dir)
.await
.context("Could not prepare wallet data directory")?;
@@ -23,15 +23,29 @@ async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> {
.map_err(|_| anyhow::anyhow!("Wallet mutation lock registry is unavailable"))?;
registry.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = registry.get(&canonical).and_then(Weak::upgrade) {
return Ok(lock);
return Ok((canonical, lock));
}
let lock = Arc::new(Mutex::new(()));
registry.insert(canonical, Arc::downgrade(&lock));
Ok(lock)
registry.insert(canonical.clone(), Arc::downgrade(&lock));
Ok((canonical, lock))
}
pub(super) async fn guard(data_dir: &Path) -> Result<OwnedMutexGuard<()>> {
Ok(lock_for(data_dir).await?.lock_owned().await)
pub(super) struct WalletMutation {
pub(super) data_dir: PathBuf,
_held: OwnedMutexGuard<()>,
}
pub(super) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
let (data_dir, lock) = canonical_lock(data_dir).await?;
Ok(WalletMutation {
data_dir,
_held: lock.lock_owned().await,
})
}
#[cfg(test)]
async fn lock_for(data_dir: &Path) -> Result<Arc<Mutex<()>>> {
Ok(canonical_lock(data_dir).await?.1)
}
#[cfg(test)]
+560
View File
@@ -0,0 +1,560 @@
//! Private write-ahead records for recoverable sends. Kept separate from the
//! legacy purse so an older purse writer cannot discard recovery metadata.
//! This module does not itself spend, reserve proofs, or authorize a purchase.
use super::{
cashu::Proof, ecash::EcashNetwork, mint_client::PreparedSwap, mutation::WalletMutation,
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use tokio::{
fs,
io::{AsyncReadExt, AsyncWriteExt},
};
const MAX_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Binding {
pub id: String,
pub network: EcashNetwork,
pub mint_url: String,
pub amount_sats: u64,
/// Hash of immutable caller-owned purchase/transfer terms, never a token.
pub context_hash: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wallet::{cashu::CashuToken, mutation};
fn fixture() -> (Binding, Request, Outcome) {
let binding = Binding {
id: uuid::Uuid::new_v4().to_string(),
network: EcashNetwork::Mainnet,
mint_url: "https://mint.example".into(),
amount_sats: 2,
context_hash: "ab".repeat(32),
};
// Storage fixture only; mint signatures are exercised by payment_tests.
let proofs = vec![Proof {
amount: 2,
id: "009a1f293253e41e".into(),
secret: "private-journal-fixture".into(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(),
}];
let outcome = Outcome {
token: CashuToken::new(&binding.mint_url, proofs.clone())
.serialize()
.unwrap(),
change: vec![],
};
(binding, Request::Exact { proofs }, outcome)
}
#[tokio::test]
async fn restart_preserves_original_request_and_private_files() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let (binding, request, _) = fixture();
{
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
assert!(journal.load(&binding.id).await.unwrap().is_none());
let record = journal
.prepare(binding.clone(), request.clone())
.await
.unwrap();
assert!(!format!("{record:?}").contains("private-journal-fixture"));
let path = journal.path(&binding.id).unwrap();
assert_eq!(
fs::metadata(&path).await.unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::metadata(path.parent().unwrap())
.await
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
}
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let original = journal.load(&binding.id).await.unwrap().unwrap();
// A retry cannot replace material potentially already sent to the mint.
let retry = journal
.prepare(binding, Request::Exact { proofs: vec![] })
.await
.unwrap();
assert_eq!(
serde_json::to_value(original).unwrap(),
serde_json::to_value(retry).unwrap()
);
}
#[tokio::test]
async fn changed_terms_never_replace_original_record() {
let root = tempfile::tempdir().unwrap();
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let (binding, request, _) = fixture();
journal
.prepare(binding.clone(), request.clone())
.await
.unwrap();
let path = journal.path(&binding.id).unwrap();
let before = fs::read(&path).await.unwrap();
let mut variants = vec![binding.clone(); 4];
variants[0].amount_sats = 4;
variants[1].mint_url = "https://different.example".into();
variants[2].context_hash = "cd".repeat(32);
variants[3].network = EcashNetwork::Testnet;
for changed in variants {
assert!(journal.prepare(changed, request.clone()).await.is_err());
assert_eq!(fs::read(&path).await.unwrap(), before);
}
}
#[tokio::test]
async fn phase_transitions_require_durable_result_and_are_idempotent() {
let root = tempfile::tempdir().unwrap();
let (binding, request, outcome) = fixture();
{
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
assert!(journal
.record_result(&binding, outcome.clone())
.await
.is_err());
journal.prepare(binding.clone(), request).await.unwrap();
assert!(journal.mark_committed(&binding).await.is_err());
journal
.record_result(&binding, outcome.clone())
.await
.unwrap();
}
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
assert!(matches!(
journal.load(&binding.id).await.unwrap().unwrap().phase,
Phase::Result(_)
));
journal
.record_result(&binding, outcome.clone())
.await
.unwrap();
journal.mark_committed(&binding).await.unwrap();
journal.mark_committed(&binding).await.unwrap();
assert!(matches!(
journal
.record_result(&binding, outcome)
.await
.unwrap()
.phase,
Phase::Committed(_)
));
}
#[tokio::test]
async fn invalid_outcomes_preserve_prepared_record() {
let root = tempfile::tempdir().unwrap();
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let (binding, request, outcome) = fixture();
journal.prepare(binding.clone(), request).await.unwrap();
let path = journal.path(&binding.id).unwrap();
let before = fs::read(&path).await.unwrap();
let token = CashuToken::deserialize(&outcome.token).unwrap();
let mut variants = vec![outcome.clone(); 5];
variants[0].token = "broken".into();
variants[1].token = CashuToken::new("https://other.example", token.token[0].proofs.clone())
.serialize()
.unwrap();
let mut wrong = token.token[0].proofs.clone();
wrong[0].amount = 4;
variants[2].token = CashuToken::new(&binding.mint_url, wrong)
.serialize()
.unwrap();
variants[3].change = token.token[0].proofs.clone();
let mut wrong_unit = token.clone();
wrong_unit.unit = Some("usd".into());
variants[4].token = wrong_unit.serialize().unwrap();
for invalid in variants {
assert!(journal.record_result(&binding, invalid).await.is_err());
assert_eq!(fs::read(&path).await.unwrap(), before);
}
}
#[tokio::test]
async fn damaged_or_unsupported_record_cannot_be_treated_as_new_payment() {
let root = tempfile::tempdir().unwrap();
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let (binding, request, _) = fixture();
journal
.prepare(binding.clone(), request.clone())
.await
.unwrap();
let path = journal.path(&binding.id).unwrap();
let original = fs::read(&path).await.unwrap();
let mut checksum: Envelope = serde_json::from_slice(&original).unwrap();
checksum.checksum = "00".repeat(32);
let mut version: Envelope = serde_json::from_slice(&original).unwrap();
version.version = 2;
for damaged in [
vec![],
b"{".to_vec(),
serde_json::to_vec(&checksum).unwrap(),
serde_json::to_vec(&version).unwrap(),
vec![b' '; MAX_BYTES as usize + 1],
] {
fs::write(&path, &damaged).await.unwrap();
assert!(journal.load(&binding.id).await.is_err());
assert!(journal
.prepare(binding.clone(), request.clone())
.await
.is_err());
assert_eq!(fs::read(&path).await.unwrap(), damaged);
}
assert!(journal.load("../../wallet").await.is_err());
}
#[tokio::test]
async fn dangling_record_symlink_is_not_an_absent_operation() {
let root = tempfile::tempdir().unwrap();
let held = mutation::guard(root.path()).await.unwrap();
let journal = Journal::new(&held);
let (binding, request, _) = fixture();
let path = journal.path(&binding.id).unwrap();
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
std::os::unix::fs::symlink(root.path().join("missing"), &path).unwrap();
assert!(journal.load(&binding.id).await.is_err());
assert!(journal.prepare(binding, request).await.is_err());
assert!(fs::symlink_metadata(path)
.await
.unwrap()
.file_type()
.is_symlink());
}
}
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Request {
Exact { proofs: Vec<Proof> },
Swap(PreparedSwap),
}
#[derive(Clone, Serialize, Deserialize)]
pub(super) struct Outcome {
pub token: String,
pub change: Vec<Proof>,
}
#[derive(Clone, Serialize, Deserialize)]
pub(super) enum Phase {
Prepared,
Result(Outcome),
Committed(Outcome),
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Record {
pub binding: Binding,
pub request: Request,
pub phase: Phase,
}
impl std::fmt::Debug for Record {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SendJournalRecord")
.field("id", &self.binding.id)
.field("amount_sats", &self.binding.amount_sats)
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Envelope {
version: u8,
payload: String,
checksum: String,
}
pub(super) struct Journal<'a> {
guard: &'a WalletMutation,
}
impl<'a> Journal<'a> {
pub fn new(guard: &'a WalletMutation) -> Self {
Self { guard }
}
fn path(&self, id: &str) -> Result<PathBuf> {
let id = uuid::Uuid::parse_str(id).context("Invalid payment operation identifier")?;
Ok(self
.guard
.data_dir
.join("wallet/send-operations")
.join(format!("{id}.json")))
}
fn validate_binding(binding: &Binding) -> Result<()> {
let id =
uuid::Uuid::parse_str(&binding.id).context("Invalid payment operation identifier")?;
anyhow::ensure!(
id.to_string() == binding.id,
"Payment operation identifier is not canonical"
);
anyhow::ensure!(binding.amount_sats > 0, "Payment amount must be positive");
anyhow::ensure!(
binding.context_hash.len() == 64
&& binding
.context_hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"Invalid payment context hash"
);
let url = reqwest::Url::parse(&binding.mint_url).context("Invalid payment mint")?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https")
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"Invalid payment mint URL"
);
Ok(())
}
fn validate_request(binding: &Binding, request: &Request) -> Result<()> {
match request {
Request::Exact { proofs } => {
let total = proofs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Payment input amount overflow")?;
anyhow::ensure!(
total == binding.amount_sats && !proofs.is_empty(),
"Exact payment inputs do not match the amount"
);
let mut secrets = std::collections::HashSet::new();
for proof in proofs {
anyhow::ensure!(
proof.amount.is_power_of_two() && secrets.insert(&proof.secret),
"Invalid or duplicate payment input"
);
proof.c_as_pubkey()?;
}
}
Request::Swap(prepared) => prepared.validate_for_mint(&binding.mint_url)?,
}
Ok(())
}
fn validate_outcome(record: &Record, outcome: &Outcome) -> Result<()> {
let token = super::cashu::CashuToken::deserialize(&outcome.token)
.map_err(|_| anyhow::anyhow!("Payment result token is invalid"))?;
anyhow::ensure!(
token.unit.as_deref().unwrap_or("sat") == "sat",
"Payment result is not denominated in sats"
);
anyhow::ensure!(
token.token.len() == 1
&& token.token[0].mint.trim_end_matches('/')
== record.binding.mint_url.trim_end_matches('/'),
"Payment result belongs to a different mint"
);
let proofs = &token.token[0].proofs;
let amount = proofs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Payment result amount overflow")?;
anyhow::ensure!(
amount == record.binding.amount_sats,
"Payment result amount changed"
);
match &record.request {
Request::Exact { proofs: expected } => {
anyhow::ensure!(
outcome.change.is_empty()
&& serde_json::to_value(proofs)? == serde_json::to_value(expected)?,
"Exact payment result changed its proofs"
);
}
Request::Swap(prepared) => {
let mut all = proofs.clone();
all.extend(outcome.change.iter().cloned());
prepared.validate_result_proofs(&all)?;
}
}
Ok(())
}
pub async fn load(&self, id: &str) -> Result<Option<Record>> {
let path = self.path(id)?;
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
let file = match options.open(path).await {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("Could not read payment recovery record"),
};
anyhow::ensure!(
file.metadata().await?.is_file(),
"Payment recovery record is not a regular file"
);
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
anyhow::ensure!(
bytes.len() as u64 <= MAX_BYTES,
"Payment recovery record exceeds its size limit"
);
let envelope: Envelope = serde_json::from_slice(&bytes)
.map_err(|_| anyhow::anyhow!("Payment recovery record is damaged; do not pay again"))?;
anyhow::ensure!(
envelope.version == 1,
"Unsupported payment recovery record version"
);
anyhow::ensure!(
envelope.checksum == hex::encode(Sha256::digest(envelope.payload.as_bytes())),
"Payment recovery record checksum failed; do not pay again"
);
let record: Record = serde_json::from_str(&envelope.payload).map_err(|_| {
anyhow::anyhow!("Payment recovery record contents are damaged; do not pay again")
})?;
Self::validate_binding(&record.binding)?;
Self::validate_request(&record.binding, &record.request)?;
match &record.phase {
Phase::Prepared => (),
Phase::Result(outcome) | Phase::Committed(outcome) => {
Self::validate_outcome(&record, outcome)?
}
}
anyhow::ensure!(
record.binding.id == id,
"Payment recovery record identity mismatch"
);
Ok(Some(record))
}
/// Repeating the same binding returns the original immutable request.
/// A new preparation must never replace a request that may already be sent.
pub async fn prepare(&self, binding: Binding, request: Request) -> Result<Record> {
Self::validate_binding(&binding)?;
if let Some(record) = self.load(&binding.id).await? {
anyhow::ensure!(
record.binding == binding,
"Payment operation terms changed; no new spend allowed"
);
return Ok(record);
}
Self::validate_request(&binding, &request)?;
let record = Record {
binding,
request,
phase: Phase::Prepared,
};
self.write(&record).await?;
Ok(record)
}
pub async fn record_result(&self, binding: &Binding, outcome: Outcome) -> Result<Record> {
let mut record = self
.load(&binding.id)
.await?
.context("Payment recovery record is missing")?;
anyhow::ensure!(
&record.binding == binding,
"Payment operation terms changed"
);
Self::validate_outcome(&record, &outcome)?;
match &record.phase {
Phase::Prepared => record.phase = Phase::Result(outcome),
Phase::Result(previous) | Phase::Committed(previous) => {
anyhow::ensure!(
serde_json::to_value(previous)? == serde_json::to_value(&outcome)?,
"Payment operation already has a different result"
);
return Ok(record);
}
}
self.write(&record).await?;
Ok(record)
}
/// Call only after the wallet commit is durable. This cannot skip Result.
pub async fn mark_committed(&self, binding: &Binding) -> Result<Record> {
let mut record = self
.load(&binding.id)
.await?
.context("Payment recovery record is missing")?;
anyhow::ensure!(
&record.binding == binding,
"Payment operation terms changed"
);
record.phase = match record.phase {
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome),
};
self.write(&record).await?;
Ok(record)
}
async fn write(&self, record: &Record) -> Result<()> {
let payload = serde_json::to_string(record)?;
let checksum = hex::encode(Sha256::digest(payload.as_bytes()));
let bytes = serde_json::to_vec(&Envelope {
version: 1,
payload,
checksum,
})?;
anyhow::ensure!(
bytes.len() as u64 <= MAX_BYTES,
"Payment recovery record exceeds its size limit"
);
let path = self.path(&record.binding.id)?;
let parent = path
.parent()
.context("Payment recovery directory is missing")?;
fs::create_dir_all(parent).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).await?;
}
struct Temporary(PathBuf);
impl Drop for Temporary {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let temporary = Temporary(parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary.0).await?;
file.write_all(&bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temporary.0, &path).await?;
// Persist every new directory entry down from the existing node root.
for directory in [
parent.to_path_buf(),
self.guard.data_dir.join("wallet"),
self.guard.data_dir.clone(),
] {
fs::File::open(directory).await?.sync_all().await?;
}
Ok(())
}
}