The object store works where hard links are refused (closes #141) c6cec7822aff
Android forbids apps hard links in their own data, so the phone runner could clone nothing. Where a link is refused, objects are renamed into place with renameat2(RENAME_NOREPLACE): as atomic, and still never over a name that is taken.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 779e1609e9ab
- When
- · 2026-10-25 15:53 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c6cec7822aff5221454694444f079cd6fa8cdb0ffb6e593eb611b65e26f04d70- Depends on
- 648189388f1a a5f76c79666c
- Depended on by
- 6a9414d6a966
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 6 atoms, 0 tree operations, 1 file
Browse the tree at this change
modified crates/oxy-core/src/strata.rs +64 −3
| … | 293 unchanged lines |
||
| 294 | 294 | .map(|d| d.as_millis() as u64) |
|
| 295 | 295 | } |
|
| 296 | 296 | ||
| 297 | + | /// Put `tmp` at `path` only if `path` is free, as `fs::hard_link` does |
|
| 298 | + | /// (`tmp` may remain). Android forbids apps hard links in their own data |
|
| 299 | + | /// (oxygen#141): there a create-only rename does the same, atomically. |
|
| 300 | + | fn place_new(tmp: &Path, path: &Path) -> io::Result<()> { |
|
| 301 | + | match fs::hard_link(tmp, path) { |
|
| 302 | + | Err(e) if e.kind() == io::ErrorKind::PermissionDenied => rename_new(tmp, path), |
|
| 303 | + | r => r, |
|
| 304 | + | } |
|
| 305 | + | } |
|
| 306 | + | ||
| 307 | + | #[cfg(any(target_os = "linux", target_os = "android"))] |
|
| 308 | + | fn rename_new(tmp: &Path, path: &Path) -> io::Result<()> { |
|
| 309 | + | use std::ffi::CString; |
|
| 310 | + | use std::os::unix::ffi::OsStrExt; |
|
| 311 | + | let from = CString::new(tmp.as_os_str().as_bytes())?; |
|
| 312 | + | let to = CString::new(path.as_os_str().as_bytes())?; |
|
| 313 | + | let r = unsafe { |
|
| 314 | + | libc::renameat2( |
|
| 315 | + | libc::AT_FDCWD, |
|
| 316 | + | from.as_ptr(), |
|
| 317 | + | libc::AT_FDCWD, |
|
| 318 | + | to.as_ptr(), |
|
| 319 | + | libc::RENAME_NOREPLACE, |
|
| 320 | + | ) |
|
| 321 | + | }; |
|
| 322 | + | if r == 0 { |
|
| 323 | + | Ok(()) |
|
| 324 | + | } else { |
|
| 325 | + | Err(io::Error::last_os_error()) |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | ||
| 329 | + | #[cfg(not(any(target_os = "linux", target_os = "android")))] |
|
| 330 | + | fn rename_new(tmp: &Path, path: &Path) -> io::Result<()> { |
|
| 331 | + | if path.exists() { |
|
| 332 | + | return Err(io::ErrorKind::AlreadyExists.into()); |
|
| 333 | + | } |
|
| 334 | + | fs::rename(tmp, path) |
|
| 335 | + | } |
|
| 336 | + | ||
| 297 | 337 | impl LocalStore { |
|
| 298 | 338 | /// Link `tmp` into `path`, create-only; the directory is synced and |
|
| 299 | 339 | /// `tmp` removed either way. `Exists` when the name is taken. |
|
| 300 | 340 | fn link_into_place(&self, key: &str, tmp: &Path, path: &Path) -> Result<()> { |
|
| 301 | - | let linked = fs::hard_link(tmp, path); |
|
| 341 | + | let linked = place_new(tmp, path); |
|
| 302 | 342 | let _ = fs::remove_file(tmp); |
|
| 303 | 343 | match linked { |
|
| 304 | 344 | Ok(()) => { |
|
| … | 78 unchanged lines |
||
| 383 | 423 | crate::durable::step("rename loose objects")?; |
|
| 384 | 424 | let mut dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new(); |
|
| 385 | 425 | for (path, tmp, _) in &todo { |
|
| 386 | - | let linked = fs::hard_link(tmp, path); |
|
| 426 | + | let linked = place_new(tmp, path); |
|
| 387 | 427 | let _ = fs::remove_file(tmp); |
|
| 388 | 428 | match linked { |
|
| 389 | 429 | Ok(()) => { |
|
| … | 19 unchanged lines |
||
| 409 | 449 | return Err(Error::Exists(key.to_string())); |
|
| 410 | 450 | } |
|
| 411 | 451 | self.ensure_dir(&path)?; |
|
| 412 | - | match fs::hard_link(file, &path) { |
|
| 452 | + | match place_new(file, &path) { |
|
| 413 | 453 | Ok(()) => { |
|
| 414 | 454 | let _ = fs::remove_file(file); |
|
| 415 | 455 | if let Some(dir) = path.parent() { |
|
| … | 381 unchanged lines |
||
| 797 | 837 | mod tests { |
|
| 798 | 838 | use super::*; |
|
| 799 | 839 | use std::sync::Arc; |
|
| 840 | + | ||
| 841 | + | /// Where hard links are refused (Android, oxygen#141), objects are |
|
| 842 | + | /// renamed into place, still never over a name that is taken. |
|
| 843 | + | #[test] |
|
| 844 | + | fn a_create_only_rename_never_replaces() { |
|
| 845 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 846 | + | let (a, b, to) = ( |
|
| 847 | + | dir.path().join("a"), |
|
| 848 | + | dir.path().join("b"), |
|
| 849 | + | dir.path().join("to"), |
|
| 850 | + | ); |
|
| 851 | + | fs::write(&a, b"first").unwrap(); |
|
| 852 | + | fs::write(&b, b"second").unwrap(); |
|
| 853 | + | rename_new(&a, &to).unwrap(); |
|
| 854 | + | assert_eq!( |
|
| 855 | + | rename_new(&b, &to).unwrap_err().kind(), |
|
| 856 | + | io::ErrorKind::AlreadyExists |
|
| 857 | + | ); |
|
| 858 | + | assert_eq!(fs::read(&to).unwrap(), b"first"); |
|
| 859 | + | assert!(!a.exists() && b.exists()); |
|
| 860 | + | } |
|
| 800 | 861 | ||
| 801 | 862 | #[test] |
|
| 802 | 863 | fn keys_are_plain_relative_paths() { |
|
| … | 116 unchanged lines |