OXYGENHUB

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