use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::{CHECKSUM_PREFIX_LEN, ObjectStore};
use crate::checksum;
use crate::config::RestoreMethod;
pub(super) static NEXT_TMP_ID: AtomicU64 = AtomicU64::new(0);
impl ObjectStore {
pub fn calculate_checksum_bytes(data: &[u8]) -> String {
checksum::bytes_checksum(data)
}
pub(super) fn object_path(&self, checksum: &str) -> PathBuf {
let (prefix, rest) = checksum.split_at(CHECKSUM_PREFIX_LEN.min(checksum.len()));
self.objects_dir.join(prefix).join(rest)
}
pub(super) fn compressed_object_path(&self, checksum: &str) -> PathBuf {
let mut path = self.object_path(checksum).into_os_string();
path.push(".zst");
PathBuf::from(path)
}
pub(super) fn object_size(&self, checksum: &str) -> u64 {
for path in [
self.object_path(checksum),
self.compressed_object_path(checksum),
] {
if let Ok(metadata) = fs::metadata(&path) {
return metadata.len();
}
}
0
}
pub(super) fn store_object(&self, content: &[u8]) -> Result<String> {
let checksum = Self::calculate_checksum_bytes(content);
let object_path = if self.compression {
self.compressed_object_path(&checksum)
} else {
self.object_path(&checksum)
};
if object_path.exists() {
return Ok(checksum);
}
let parent = object_path
.parent()
.context("Object path has no parent directory")?;
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create object directory: {}", parent.display()))?;
let blob = if self.compression {
zstd::encode_all(content, 0).context("Failed to zstd-compress object")?
} else {
content.to_vec()
};
let tmp_path = parent.join(format!(
".tmp-{}-{}",
std::process::id(),
NEXT_TMP_ID.fetch_add(1, Ordering::Relaxed)
));
fs::write(&tmp_path, &blob)
.with_context(|| format!("Failed to write object temp file: {}", tmp_path.display()))?;
let mut perms = fs::metadata(&tmp_path)
.with_context(|| {
format!(
"Failed to read object temp file metadata: {}",
tmp_path.display()
)
})?
.permissions();
perms.set_readonly(true);
fs::set_permissions(&tmp_path, perms).with_context(|| {
format!(
"Failed to set object temp file read-only: {}",
tmp_path.display()
)
})?;
if let Err(e) = fs::rename(&tmp_path, &object_path) {
let mut writable = fs::metadata(&tmp_path).map(|m| m.permissions());
if let Ok(ref mut perms) = writable {
perms.set_readonly(false);
let _ = fs::set_permissions(&tmp_path, perms.clone());
}
let _ = fs::remove_file(&tmp_path);
if !object_path.exists() {
return Err(e).with_context(|| {
format!(
"Failed to move object into place: {}",
object_path.display()
)
});
}
}
Ok(checksum)
}
pub(super) fn has_object(&self, checksum: &str) -> bool {
self.object_path(checksum).exists() || self.compressed_object_path(checksum).exists()
}
pub(super) fn restore_file(
&self,
checksum: &str,
output_path: &Path,
mode: Option<u32>,
) -> Result<()> {
let object_path = self.object_path(checksum);
if !object_path.exists() {
let content = self
.read_object(checksum)
.with_context(|| format!("Failed to read cached object: {checksum}"))?;
fs::write(output_path, &content).with_context(|| {
format!(
"Failed to write decompressed output: {}",
output_path.display()
)
})?;
crate::platform::set_permissions_mode(output_path, mode.unwrap_or(0o644))
.with_context(|| {
format!(
"Failed to set permissions on restored file: {}",
output_path.display()
)
})?;
return Ok(());
}
let needs_exec = mode.is_some_and(|m| m & 0o111 != 0);
match self.restore_method {
RestoreMethod::Hardlink if !needs_exec => {
fs::hard_link(&object_path, output_path)
.with_context(|| format!("Failed to hard link from cache: {checksum}. If on a cross-filesystem setup, set restore_method = \"copy\" in rsconstruct.toml."))?;
}
RestoreMethod::Hardlink | RestoreMethod::Copy => {
fs::copy(&object_path, output_path)
.with_context(|| format!("Failed to copy from cache: {checksum}"))?;
crate::platform::set_permissions_mode(output_path, mode.unwrap_or(0o644))
.with_context(|| {
format!(
"Failed to set permissions on restored file: {}",
output_path.display()
)
})?;
}
RestoreMethod::Auto => unreachable!("Auto should be resolved before use"),
}
Ok(())
}
pub(crate) fn read_object(&self, checksum: &str) -> Result<Vec<u8>> {
let plain_path = self.object_path(checksum);
if plain_path.exists() {
return fs::read(&plain_path)
.with_context(|| format!("Failed to read object: {checksum}"));
}
let compressed_path = self.compressed_object_path(checksum);
let raw = fs::read(&compressed_path)
.with_context(|| format!("Failed to read object: {checksum}"))?;
zstd::decode_all(raw.as_slice())
.with_context(|| format!("Failed to decompress object: {checksum}"))
}
pub(super) fn path_string(path: &Path) -> String {
path.display().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_db(dir: &Path, db_name: &str) -> ObjectStore {
ObjectStore::new_at(dir, db_name)
}
fn store_in(dir: &Path) -> ObjectStore {
ObjectStore::new_in(dir)
}
#[test]
fn hardlink_restore_keeps_object_read_only() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let checksum = store.store_object(b"cached content").unwrap();
let out = tmp.path().join("out.txt");
store.restore_file(&checksum, &out, Some(0o644)).unwrap();
let obj_meta = fs::metadata(store.object_path(&checksum)).unwrap();
assert!(
obj_meta.permissions().readonly(),
"cache object must stay read-only after a hardlink restore"
);
let mode = crate::platform::get_mode(&fs::metadata(&out).unwrap());
assert_eq!(
mode & 0o222,
0,
"hardlink-restored output shares the object inode and must stay read-only"
);
}
#[test]
fn compression_toggle_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let compressed_store = ObjectStore {
compression: true,
..store_with_db(tmp.path(), "db1.redb")
};
let checksum = compressed_store
.store_object(b"compressed at store time")
.unwrap();
assert!(compressed_store.compressed_object_path(&checksum).exists());
let plain_store = store_with_db(tmp.path(), "db2.redb");
assert!(
plain_store.has_object(&checksum),
"toggled store must still see the object"
);
assert_eq!(
plain_store.read_object(&checksum).unwrap(),
b"compressed at store time",
"read must decompress based on the object's actual format"
);
let out = tmp.path().join("restored.txt");
plain_store
.restore_file(&checksum, &out, Some(0o644))
.unwrap();
assert_eq!(
fs::read(&out).unwrap(),
b"compressed at store time",
"restore must never emit raw zstd bytes as file content"
);
let checksum2 = plain_store.store_object(b"plain at store time").unwrap();
assert_eq!(
compressed_store.read_object(&checksum2).unwrap(),
b"plain at store time"
);
}
#[test]
fn exec_mode_restores_via_copy() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let checksum = store.store_object(b"#!/bin/sh\n").unwrap();
let out = tmp.path().join("script.sh");
store.restore_file(&checksum, &out, Some(0o755)).unwrap();
assert!(
fs::metadata(store.object_path(&checksum))
.unwrap()
.permissions()
.readonly(),
"cache object must stay read-only after an exec-mode restore"
);
let mode = crate::platform::get_mode(&fs::metadata(&out).unwrap());
assert_ne!(mode & 0o111, 0, "restored script must be executable");
assert_ne!(mode & 0o200, 0, "copy-restored file must be writable");
}
}