use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CowAction {
NoFile,
AlreadyPrivate,
BrokeSymlink,
BrokeHardlink,
}
pub async fn break_hardlink_if_needed(path: &Path) -> std::io::Result<CowAction> {
let lstat = match tokio::fs::symlink_metadata(path).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CowAction::NoFile),
Err(e) => return Err(e),
};
if lstat.file_type().is_symlink() {
let target_bytes = tokio::fs::read(path).await?;
tokio::fs::remove_file(path).await?;
write_via_stage_rename(path, &target_bytes).await?;
return Ok(CowAction::BrokeSymlink);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if lstat.nlink() > 1 {
let content = tokio::fs::read(path).await?;
write_via_stage_rename(path, &content).await?;
return Ok(CowAction::BrokeHardlink);
}
}
Ok(CowAction::AlreadyPrivate)
}
async fn write_via_stage_rename(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let parent = path
.parent()
.expect("cow stage path always has a parent — callers pass package-internal files");
let stem = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.expect("cow stage path always has a file_name — callers pass package-internal files");
let stage: PathBuf = parent.join(format!(
".socket-cow-{}-{}",
stem,
uuid::Uuid::new_v4()
));
tokio::fs::write(&stage, bytes).await?;
match tokio::fs::rename(&stage, path).await {
Ok(()) => Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(&stage).await;
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn missing_file_is_noop() {
let dir = tempfile::tempdir().unwrap();
let action = break_hardlink_if_needed(&dir.path().join("nope.txt"))
.await
.unwrap();
assert_eq!(action, CowAction::NoFile);
}
#[tokio::test]
async fn regular_file_with_one_link_is_already_private() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.txt");
tokio::fs::write(&p, b"hello").await.unwrap();
let action = break_hardlink_if_needed(&p).await.unwrap();
assert_eq!(action, CowAction::AlreadyPrivate);
assert_eq!(tokio::fs::read(&p).await.unwrap(), b"hello");
}
#[cfg(unix)]
#[tokio::test]
async fn hardlink_is_broken_and_sibling_survives_mutation() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("store-a.txt");
let b = dir.path().join("project-b.txt");
tokio::fs::write(&a, b"original").await.unwrap();
tokio::fs::hard_link(&a, &b).await.unwrap();
let a_meta_before = tokio::fs::metadata(&a).await.unwrap();
assert_eq!(a_meta_before.nlink(), 2);
let action = break_hardlink_if_needed(&b).await.unwrap();
assert_eq!(action, CowAction::BrokeHardlink);
let a_meta_after = tokio::fs::metadata(&a).await.unwrap();
assert_eq!(a_meta_after.nlink(), 1);
assert_eq!(tokio::fs::read(&b).await.unwrap(), b"original");
assert_ne!(
a_meta_after.ino(),
tokio::fs::metadata(&b).await.unwrap().ino()
);
tokio::fs::write(&b, b"patched").await.unwrap();
assert_eq!(tokio::fs::read(&a).await.unwrap(), b"original");
assert_eq!(tokio::fs::read(&b).await.unwrap(), b"patched");
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_is_replaced_with_private_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("store-entry.txt");
let link = dir.path().join("project-link.txt");
tokio::fs::write(&target, b"shared bytes").await.unwrap();
tokio::fs::symlink(&target, &link).await.unwrap();
let action = break_hardlink_if_needed(&link).await.unwrap();
assert_eq!(action, CowAction::BrokeSymlink);
let link_meta = tokio::fs::symlink_metadata(&link).await.unwrap();
assert!(link_meta.file_type().is_file());
assert!(!link_meta.file_type().is_symlink());
assert_eq!(tokio::fs::read(&link).await.unwrap(), b"shared bytes");
let target_meta = tokio::fs::symlink_metadata(&target).await.unwrap();
assert!(target_meta.file_type().is_file());
assert_eq!(tokio::fs::read(&target).await.unwrap(), b"shared bytes");
tokio::fs::write(&link, b"patched").await.unwrap();
assert_eq!(tokio::fs::read(&target).await.unwrap(), b"shared bytes");
}
#[tokio::test]
async fn idempotent_on_regular_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("x.txt");
tokio::fs::write(&p, b"hi").await.unwrap();
let a1 = break_hardlink_if_needed(&p).await.unwrap();
let a2 = break_hardlink_if_needed(&p).await.unwrap();
assert_eq!(a1, CowAction::AlreadyPrivate);
assert_eq!(a2, CowAction::AlreadyPrivate);
}
}