Watch
0
0
Fork
You've already forked hyperhive
0

chown only a lock file swarmctl just created and refuse a hard-linked one

This commit is contained in:
atlas 2026-10-17 14:56:39 +02:00 • committed by mara
commit 6b93839290
3 changed files with 149 additions and 34 deletions

View file

@ -56,9 +56,12 @@ What still has to hold, since two processes share the file:
hold it until the rename lands, waiting at most 10 seconds. Reading the
file first is not enough: both writers spend about a second minting a
password between load and write, and the second rename would drop the
first writer's user. The lock file is opened `O_NOFOLLOW`, so neither side
can be pointed at another file through it. `swarmctl` is root and hands
the file it creates to the directory's owner, so the bridge can open it.
first writer's user. The lock file is opened `O_NOFOLLOW`, so a symlink
cannot point either side at another file. `swarmctl` is root: it refuses an
existing lock file with more than one hard link, and it changes the owner
of a lock file only when it created that file in the same call, handing it
to the directory's owner so the bridge can open it. The bridge opens the
lock read-only, which is all `flock` needs.
- **Unknown keys survive.** Both carry unmodelled top-level and per-user
fields through a round-trip, or whichever writes second would silently
delete what the first added.

View file

@ -19,7 +19,7 @@
use std::collections::BTreeMap;
use std::fs::{self, File, Permissions};
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use std::time::Duration;
@ -305,15 +305,19 @@ pub async fn lock_users_file(users_file: &Path, wait: Duration) -> Result<File>
lock_name.push(".lock");
let lock_path = dir.join(lock_name);
let flags = rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK;
let file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(flags.bits().cast_signed())
.open(&lock_path)
.with_context(|| format!("opening {}", lock_path.display()))?;
// Read-only: `flock` needs no write access, and a lock file `swarmctl`
// (root) left behind readable but not writable must still be lockable.
let fd = rustix::fs::open(
&lock_path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::NONBLOCK
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.with_context(|| format!("opening {}", lock_path.display()))?;
let file = File::from(fd);
if !file
.metadata()
.with_context(|| format!("stat {}", lock_path.display()))?
@ -741,6 +745,23 @@ some_future_top_level_key: 7
fs::remove_dir_all(&dir).ok();
}
/// A lock file left behind without write access (root-owned after a crash
/// in `swarmctl`) can still be locked: `flock` needs only a read fd.
#[tokio::test]
async fn a_read_only_lock_file_can_still_be_locked() {
let dir = tempdir();
let users_file = dir.join("users.yml");
let lock_path = dir.join("users.yml.lock");
fs::write(&lock_path, "").unwrap();
fs::set_permissions(&lock_path, Permissions::from_mode(0o444)).unwrap();
lock_users_file(&users_file, Duration::from_secs(1))
.await
.expect("lock a read-only lock file");
fs::remove_dir_all(&dir).ok();
}
/// Two read-modify-writes that each sit between load and publish long
/// enough for the other to load: without the lock the second `publish`
/// drops the first user.