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?;
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()));
if let Err(e) = tokio::fs::write(&stage, bytes).await {
let _ = tokio::fs::remove_file(&stage).await;
return Err(e);
}
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");
}
#[cfg(unix)]
fn leftover_stage_count(dir: &Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-"))
.count()
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_to_hardlinked_store_entry_is_fully_isolated() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("store-entry.txt");
let sibling = dir.path().join("other-project-hardlink.txt");
tokio::fs::write(&store, b"shared bytes").await.unwrap();
tokio::fs::hard_link(&store, &sibling).await.unwrap();
let link = dir.path().join("our-project-link.txt");
tokio::fs::symlink(&store, &link).await.unwrap();
assert_eq!(tokio::fs::metadata(&store).await.unwrap().nlink(), 2);
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_ne!(
link_meta.ino(),
tokio::fs::metadata(&store).await.unwrap().ino()
);
assert_eq!(tokio::fs::metadata(&store).await.unwrap().nlink(), 2);
assert_eq!(tokio::fs::read(&store).await.unwrap(), b"shared bytes");
assert_eq!(tokio::fs::read(&sibling).await.unwrap(), b"shared bytes");
tokio::fs::write(&link, b"patched").await.unwrap();
assert_eq!(tokio::fs::read(&store).await.unwrap(), b"shared bytes");
assert_eq!(tokio::fs::read(&sibling).await.unwrap(), b"shared bytes");
assert_eq!(leftover_stage_count(dir.path()), 0);
}
#[cfg(unix)]
#[tokio::test]
async fn break_leaves_no_stage_litter() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("t.txt");
tokio::fs::write(&target, b"x").await.unwrap();
let link = dir.path().join("l.txt");
tokio::fs::symlink(&target, &link).await.unwrap();
break_hardlink_if_needed(&link).await.unwrap();
let a = dir.path().join("a.txt");
tokio::fs::write(&a, b"y").await.unwrap();
let b = dir.path().join("b.txt");
tokio::fs::hard_link(&a, &b).await.unwrap();
break_hardlink_if_needed(&b).await.unwrap();
assert_eq!(leftover_stage_count(dir.path()), 0);
}
#[cfg(unix)]
#[tokio::test]
async fn idempotent_after_breaking_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("store.txt");
let link = dir.path().join("link.txt");
tokio::fs::write(&target, b"bytes").await.unwrap();
tokio::fs::symlink(&target, &link).await.unwrap();
assert_eq!(
break_hardlink_if_needed(&link).await.unwrap(),
CowAction::BrokeSymlink
);
assert_eq!(
break_hardlink_if_needed(&link).await.unwrap(),
CowAction::AlreadyPrivate
);
assert_eq!(leftover_stage_count(dir.path()), 0);
}
#[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);
}
}