[PATCH proxmox-backup 16/20] sync: pull: introduce and use decrypt index writer if crypt config

Christian Ebner
2026-04-01T07:56:15Z

In order to decrypt and encrypted index file during a pull sync job
when a matching decryption key is configured, the index has to be
rewritten as the chunks has to be decrypted and the new digests
calculated based on the decrypted chunk. The newly written index file
need to finally replace the original one, achieved by replacing the
original tempfile after pulling the chunks.

In order to be able to do so, provide a DecryptedIndexWriter instance
to the chunk pulling logic. The DecryptIndexWriter provides variants
for fix and dynamic index writers, or none if no rewriting should
happen.

This remains disarmed for the time being by never passing the crypt
config until the logic to decrypt the chunk and re-calculate the
digests is in place, done in subsequent code changes.

Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
---
src/server/pull.rs | 135 ++++++++++++++++++++++++++++++---------------
1 file changed, 89 insertions(+), 46 deletions(-)

diff --git a/src/server/pull.rs b/src/server/pull.rs
index a5d1b3079..8002bbf87 100644
--- a/src/server/pull.rs
+++ b/src/server/pull.rs
@@ -21,8 +21,8 @@ use pbs_api_types::{
use pbs_client::BackupRepository;
use pbs_config::CachedUserInfo;
use pbs_datastore::data_blob::DataBlob;
-use pbs_datastore::dynamic_index::DynamicIndexReader;
-use pbs_datastore::fixed_index::FixedIndexReader;
+use pbs_datastore::dynamic_index::{DynamicIndexReader, DynamicIndexWriter};
+use pbs_datastore::fixed_index::{FixedIndexReader, FixedIndexWriter};
use pbs_datastore::index::IndexFile;
use pbs_datastore::manifest::{BackupManifest, FileInfo};
use pbs_datastore::read_chunk::AsyncReadChunk;
@@ -155,6 +155,7 @@ async fn pull_index_chunks<I: IndexFile>(
index: I,
encountered_chunks: Arc<Mutex<EncounteredChunks>>,
backend: &DatastoreBackend,
+ decrypted_index_writer: DecryptedIndexWriter,
) -> Result<SyncStats, Error> {
use futures::stream::{self, StreamExt, TryStreamExt};

@@ -190,55 +191,61 @@ async fn pull_index_chunks<I: IndexFile>(
let bytes = Arc::new(AtomicUsize::new(0));
let chunk_count = Arc::new(AtomicUsize::new(0));

- stream
- .map(|info| {
- let target = Arc::clone(&target);
- let chunk_reader = chunk_reader.clone();
- let bytes = Arc::clone(&bytes);
- let chunk_count = Arc::clone(&chunk_count);
- let verify_and_write_channel = verify_and_write_channel.clone();
- let encountered_chunks = Arc::clone(&encountered_chunks);
-
- Ok::<_, Error>(async move {
- {
- // limit guard scope
- let mut guard = encountered_chunks.lock().unwrap();
- if let Some(touched) = guard.check_reusable(&info.digest) {
- if touched {
- return Ok::<_, Error>(());
- }
- let chunk_exists = proxmox_async::runtime::block_in_place(|| {
- target.cond_touch_chunk(&info.digest, false)
- })?;
- if chunk_exists {
- guard.mark_touched(&info.digest);
- //info!("chunk {} exists {}", pos, hex::encode(digest));
- return Ok::<_, Error>(());
- }
+ let stream = stream.map(|info| {
+ let target = Arc::clone(&target);
+ let chunk_reader = chunk_reader.clone();
+ let bytes = Arc::clone(&bytes);
+ let chunk_count = Arc::clone(&chunk_count);
+ let verify_and_write_channel = verify_and_write_channel.clone();
+ let encountered_chunks = Arc::clone(&encountered_chunks);
+
+ Ok::<_, Error>(async move {
+ {
+ // limit guard scope
+ let mut guard = encountered_chunks.lock().unwrap();
+ if let Some(touched) = guard.check_reusable(&info.digest) {
+ if touched {
+ return Ok::<_, Error>(());
+ }
+ let chunk_exists = proxmox_async::runtime::block_in_place(|| {
+ target.cond_touch_chunk(&info.digest, false)
+ })?;
+ if chunk_exists {
+ guard.mark_touched(&info.digest);
+ //info!("chunk {} exists {}", pos, hex::encode(digest));
+ return Ok::<_, Error>(());
}
- // mark before actually downloading the chunk, so this happens only once
- guard.mark_reusable(&info.digest);
- guard.mark_touched(&info.digest);
}
+ // mark before actually downloading the chunk, so this happens only once
+ guard.mark_reusable(&info.digest);
+ guard.mark_touched(&info.digest);
+ }

- //info!("sync {} chunk {}", pos, hex::encode(digest));
- let chunk = chunk_reader.read_raw_chunk(&info.digest).await?;
- let raw_size = chunk.raw_size() as usize;
+ //info!("sync {} chunk {}", pos, hex::encode(digest));
+ let chunk = chunk_reader.read_raw_chunk(&info.digest).await?;
+ let raw_size = chunk.raw_size() as usize;

- // decode, verify and write in a separate threads to maximize throughput
- proxmox_async::runtime::block_in_place(|| {
- verify_and_write_channel.send((chunk, info.digest, info.size()))
- })?;
+ // decode, verify and write in a separate threads to maximize throughput
+ proxmox_async::runtime::block_in_place(|| {
+ verify_and_write_channel.send((chunk, info.digest, info.size()))
+ })?;

- bytes.fetch_add(raw_size, Ordering::SeqCst);
- chunk_count.fetch_add(1, Ordering::SeqCst);
+ bytes.fetch_add(raw_size, Ordering::SeqCst);
+ chunk_count.fetch_add(1, Ordering::SeqCst);

- Ok(())
- })
+ Ok(())
})
- .try_buffer_unordered(20)
- .try_for_each(|_res| futures::future::ok(()))
- .await?;
+ });
+
+ if let DecryptedIndexWriter::None = decrypted_index_writer {
+ stream
+ .try_buffer_unordered(20)
+ .try_for_each(|_res| futures::future::ok(()))
+ .await?;
+ } else {
+ // must keep chunk order to correctly rewrite index file
+ stream.try_for_each(|item| item).await?;
+ }

drop(verify_and_write_channel);

@@ -319,9 +326,15 @@ async fn pull_single_archive<'a>(
let (csum, size) = index.compute_csum();
verify_archive(archive_info, &csum, size)?;

- if reader.skip_chunk_sync(snapshot.datastore().name()) {
+ if crypt_config.is_none() && reader.skip_chunk_sync(snapshot.datastore().name()) {
info!("skipping chunk sync for same datastore");
} else {
+ let new_index_writer = if crypt_config.is_some() {
+ let writer = DynamicIndexWriter::create(&path)?;
+ DecryptedIndexWriter::Dynamic(Arc::new(Mutex::new(writer)))
+ } else {
+ DecryptedIndexWriter::None
+ };
let stats = pull_index_chunks(
reader
.chunk_reader(crypt_config.clone(), archive_info.crypt_mode)
@@ -330,8 +343,16 @@ async fn pull_single_archive<'a>(
index,
encountered_chunks,
backend,
+ new_index_writer.clone(),
)
.await?;
+ if let DecryptedIndexWriter::Dynamic(index) = &new_index_writer {
+ let csum = index.lock().unwrap().close()?;
+
+ // Overwrite current tmp file so it will be persisted instead
+ std::fs::rename(&path, &tmp_path)?;
+ }
+
sync_stats.add(stats);
}
}
@@ -342,9 +363,16 @@ async fn pull_single_archive<'a>(
let (csum, size) = index.compute_csum();
verify_archive(archive_info, &csum, size)?;

- if reader.skip_chunk_sync(snapshot.datastore().name()) {
+ if crypt_config.is_none() && reader.skip_chunk_sync(snapshot.datastore().name()) {
info!("skipping chunk sync for same datastore");
} else {
+ let new_index_writer = if crypt_config.is_some() {
+ let writer =
+ FixedIndexWriter::create(&path, Some(size), index.chunk_size as u32)?;
+ DecryptedIndexWriter::Fixed(Arc::new(Mutex::new(writer)))
+ } else {
+ DecryptedIndexWriter::None
+ };
let stats = pull_index_chunks(
reader
.chunk_reader(crypt_config.clone(), archive_info.crypt_mode)
@@ -353,8 +381,16 @@ async fn pull_single_archive<'a>(
index,
encountered_chunks,
backend,
+ new_index_writer.clone(),
)
.await?;
+ if let DecryptedIndexWriter::Fixed(index) = &new_index_writer {
+ let csum = index.lock().unwrap().close()?;
+
+ // Overwrite current tmp file so it will be persisted instead
+ std::fs::rename(&path, &tmp_path)?;
+ }
+
sync_stats.add(stats);
}
}
@@ -1269,3 +1305,10 @@ impl EncounteredChunks {
self.chunk_set.clear();
}
}
+
+#[derive(Clone)]
+enum DecryptedIndexWriter {
+ Fixed(Arc<Mutex<FixedIndexWriter>>),
+ Dynamic(Arc<Mutex<DynamicIndexWriter>>),
+ None,
+}
--
2.47.3