Skip to main content

kunobi_daemon/
publication.rs

1//! Atomic publication of discovery records in an application-owned directory.
2
3use std::{
4    ffi::OsString,
5    fs::{File, OpenOptions},
6    io::{self, Read, Write},
7    path::{Path, PathBuf},
8    sync::atomic::{AtomicU64, Ordering},
9};
10
11#[cfg(windows)]
12#[allow(unsafe_code)]
13mod windows;
14#[cfg(windows)]
15use windows::{open_record, replace};
16
17static NEXT: AtomicU64 = AtomicU64::new(0);
18
19struct Temporary(PathBuf);
20impl Drop for Temporary {
21    fn drop(&mut self) {
22        let _ = std::fs::remove_file(&self.0);
23    }
24}
25
26/// How a record is written and read. The default is what [`publish_record`]
27/// and [`RecordSlot`] have always done.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct RecordOptions {
30    durable: bool,
31    #[cfg(unix)]
32    mode: u32,
33    read_limit: u64,
34}
35
36impl Default for RecordOptions {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl RecordOptions {
43    /// Not durable, mode 0600, and a 64 KiB read limit.
44    pub const fn new() -> Self {
45        Self {
46            durable: false,
47            #[cfg(unix)]
48            mode: 0o600,
49            read_limit: 64 * 1024,
50        }
51    }
52
53    /// Make the rename itself survive a crash, not only the contents.
54    ///
55    /// On Unix the parent directory is synced after the rename. Windows has no
56    /// directory sync, so the record is flushed again after the rename,
57    /// through the publisher's own handle. On a file system without POSIX
58    /// renames the record is reopened by name for that, and the open waits out
59    /// a concurrent replacement as [`RecordSlot::read`] does. An error from
60    /// that step means the record is in place but may not survive a crash.
61    pub const fn durable(mut self, durable: bool) -> Self {
62        self.durable = durable;
63        self
64    }
65
66    /// Permission bits of a published record, set before any contents are
67    /// written. Choosing anything wider than 0600 is the caller's decision.
68    #[cfg(unix)]
69    pub const fn mode(mut self, mode: u32) -> Self {
70        self.mode = mode;
71        self
72    }
73
74    /// Largest record [`RecordSlot::read`], [`RecordSlot::replace_if`] and
75    /// [`RecordSlot::remove_if`] read, in bytes. A longer one is refused.
76    /// `repair` and `remove_if_matches` keep their original comparison.
77    pub const fn read_limit(mut self, bytes: u64) -> Self {
78        self.read_limit = bytes;
79        self
80    }
81}
82
83/// Replace a discovery record atomically, after syncing its complete contents.
84///
85/// The parent directory must already exist and belong to the application.
86/// Temporary files stay on the same filesystem, are created exclusively, and
87/// are removed on failure. On Unix their permissions are set to 0600 before any
88/// contents are written. The caller remains responsible for directory security,
89/// record format, ownership and eventual discovery repair after a crash. The
90/// directory is not synced; this does not promise survival across power loss.
91/// With `durable`, [`publish_record_with`] syncs it.
92///
93/// A reader reads either the old record or the new one, never part of one.
94/// On Windows the temporary file is renamed through its own handle with POSIX
95/// semantics, falling back to `std::fs::rename` on a file system without
96/// them. Neither makes the replacement atomic for an open running at the same
97/// time: the open can fail with `PermissionDenied`, or find no record while
98/// the rename is in progress. [`RecordSlot::read`] and the slot's other reads
99/// retry both; any other reader must too. The publisher keeps its handle open
100/// for a moment after the rename, longer with `durable` while it flushes: a
101/// reader that does not share write and delete access can get a sharing
102/// violation then. `File::open` shares both.
103pub fn publish_record(path: &Path, contents: &[u8]) -> io::Result<()> {
104    publish_record_with(path, contents, RecordOptions::new())
105}
106
107/// [`publish_record`] with a chosen mode and durability.
108pub fn publish_record_with(path: &Path, contents: &[u8], options: RecordOptions) -> io::Result<()> {
109    let parent = path
110        .parent()
111        .filter(|p| !p.as_os_str().is_empty())
112        .unwrap_or_else(|| Path::new("."));
113    let filename = path.file_name().ok_or_else(|| {
114        io::Error::new(
115            io::ErrorKind::InvalidInput,
116            "discovery path needs a filename",
117        )
118    })?;
119    let (temporary, mut file): (Temporary, File) = loop {
120        let mut name = OsString::from(".");
121        name.push(filename);
122        name.push(format!(
123            ".{}.{}.tmp",
124            std::process::id(),
125            NEXT.fetch_add(1, Ordering::Relaxed)
126        ));
127        let temporary = parent.join(name);
128        let mut open = OpenOptions::new();
129        open.create_new(true).write(true);
130        #[cfg(unix)]
131        {
132            use std::os::unix::fs::OpenOptionsExt;
133            open.mode(0o600);
134        }
135        #[cfg(windows)]
136        {
137            use std::os::windows::fs::OpenOptionsExt;
138            open.access_mode(windows::ACCESS);
139        }
140        match open.open(&temporary) {
141            Ok(file) => break (Temporary(temporary), file),
142            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
143            Err(e) => return Err(e),
144        }
145    };
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::PermissionsExt;
149        file.set_permissions(std::fs::Permissions::from_mode(options.mode))?;
150    }
151    file.write_all(contents)?;
152    file.sync_all()?;
153    replace(file, &temporary.0, path, options.durable)
154}
155
156/// Close the written temporary file and rename it over the record. With
157/// `durable`, sync the directory, so the rename survives a crash.
158#[cfg(not(windows))]
159fn replace(file: File, from: &Path, to: &Path, durable: bool) -> io::Result<()> {
160    drop(file);
161    std::fs::rename(from, to)?;
162    if durable {
163        // The temporary is named inside the record's directory.
164        let directory = from.parent().expect("a temporary has a directory");
165        File::open(directory)?.sync_all()?;
166    }
167    Ok(())
168}
169
170/// Open a record. A Unix rename replaces the name in one step, so an open
171/// finds the old record or the new one.
172#[cfg(not(windows))]
173fn open_record(open: &OpenOptions, path: &Path) -> io::Result<File> {
174    open.open(path)
175}
176
177/// Read a record that must be a regular file no longer than `limit` bytes.
178/// Missing is `None`.
179///
180/// The record is opened first and checked through that handle, so what is
181/// read is the version that was opened. A symlink is not followed (Unix
182/// `O_NOFOLLOW`, Windows `FILE_FLAG_OPEN_REPARSE_POINT`), and on Unix the open
183/// does not wait for a writer if the record is a FIFO.
184fn read_regular(path: &Path, limit: u64) -> io::Result<Option<Vec<u8>>> {
185    let not_regular = || io::Error::new(io::ErrorKind::InvalidData, "record is not a regular file");
186    let mut open = OpenOptions::new();
187    open.read(true);
188    #[cfg(unix)]
189    {
190        use std::os::unix::fs::OpenOptionsExt;
191        open.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
192    }
193    #[cfg(windows)]
194    {
195        use std::os::windows::fs::OpenOptionsExt;
196        // FILE_FLAG_OPEN_REPARSE_POINT (0x0020_0000) and
197        // FILE_FLAG_BACKUP_SEMANTICS (0x0200_0000): open a link itself, and a
198        // directory at all, so both are refused below.
199        open.custom_flags(0x0220_0000);
200    }
201    let file = match open_record(&open, path) {
202        Ok(file) => file,
203        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
204        // O_NOFOLLOW reports a symlink as ELOOP.
205        #[cfg(unix)]
206        Err(error) if error.raw_os_error() == Some(libc::ELOOP) => return Err(not_regular()),
207        Err(error) => return Err(error),
208    };
209    if !file.metadata()?.is_file() {
210        return Err(not_regular());
211    }
212    let mut contents = Vec::new();
213    file.take(limit.saturating_add(1))
214        .read_to_end(&mut contents)?;
215    if contents.len() as u64 > limit {
216        return Err(io::Error::new(
217            io::ErrorKind::InvalidData,
218            "record is longer than its read limit",
219        ));
220    }
221    Ok(Some(contents))
222}
223
224/// Create `dir` if needed and check that it is private to this user.
225///
226/// The directory itself must not be a symlink, or a junction on Windows. On
227/// Unix it must belong to the effective user, and its mode becomes 0700.
228/// Either refusal is `PermissionDenied`. Ancestors are not checked. On
229/// Windows, the ACL the directory inherits decides who else may use it.
230#[cfg(feature = "local")]
231pub fn ensure_private_dir(dir: &Path) -> io::Result<()> {
232    std::fs::create_dir_all(dir)?;
233    let metadata = std::fs::symlink_metadata(dir)?;
234    if !metadata.is_dir() {
235        return Err(io::ErrorKind::PermissionDenied.into());
236    }
237    #[cfg(unix)]
238    {
239        use std::os::unix::fs::{MetadataExt, PermissionsExt};
240        if metadata.uid() != crate::local::unix::own_uid() {
241            return Err(io::ErrorKind::PermissionDenied.into());
242        }
243        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
244    }
245    Ok(())
246}
247
248/// A discovery slot whose publishers and conditional cleanup share a lock.
249/// Use the same slot path in every generation. Lock files remain persistent.
250#[derive(Clone, Debug)]
251pub struct RecordSlot(PathBuf, RecordOptions);
252impl RecordSlot {
253    /// Address an existing application-owned directory. This performs no I/O.
254    pub fn new(path: impl Into<PathBuf>) -> Self {
255        Self(path.into(), RecordOptions::new())
256    }
257
258    /// Publish and read with `options` instead of the defaults.
259    pub fn options(mut self, options: RecordOptions) -> Self {
260        self.1 = options;
261        self
262    }
263
264    fn lock(&self) -> io::Result<crate::ProcessLock> {
265        let mut lock = self.0.as_os_str().to_owned();
266        lock.push(".record.lock");
267        crate::ProcessLock::acquire_until(
268            &lock,
269            std::time::Instant::now() + std::time::Duration::from_secs(2),
270        )?
271        .ok_or_else(|| io::ErrorKind::TimedOut.into())
272    }
273
274    /// Atomically publish while excluding conditional cleanup by older owners.
275    pub fn replace(&self, contents: &[u8]) -> io::Result<()> {
276        let _lock = self.lock()?;
277        publish_record_with(&self.0, contents, self.1)
278    }
279
280    /// Restore an owned record only if its contents differ. The caller must
281    /// still be authorized to advertise this endpoint; a lock does not elect it.
282    pub fn repair(&self, contents: &[u8]) -> io::Result<bool> {
283        let _lock = self.lock()?;
284        if self.matches(contents)? {
285            return Ok(false);
286        }
287        publish_record_with(&self.0, contents, self.1)?;
288        Ok(true)
289    }
290
291    /// Publish `contents` only if `replace` accepts the current record, or
292    /// `None` when there is none. The record is read and the predicate runs
293    /// under the slot lock, so no other slot user changes it in between.
294    /// True if published.
295    pub fn replace_if(
296        &self,
297        contents: &[u8],
298        replace: impl FnOnce(Option<&[u8]>) -> bool,
299    ) -> io::Result<bool> {
300        let _lock = self.lock()?;
301        let current = read_regular(&self.0, self.1.read_limit)?;
302        if !replace(current.as_deref()) {
303            return Ok(false);
304        }
305        publish_record_with(&self.0, contents, self.1)?;
306        Ok(true)
307    }
308
309    /// Remove only the exact record still belonging to this owner. A successor's
310    /// publication cannot race between the comparison and unlink.
311    pub fn remove_if_matches(&self, contents: &[u8]) -> io::Result<bool> {
312        let _lock = self.lock()?;
313        if !self.matches(contents)? {
314            return Ok(false);
315        }
316        std::fs::remove_file(&self.0)?;
317        Ok(true)
318    }
319
320    /// Remove the record only if `remove` accepts its contents, under the
321    /// slot lock. A missing record is false, without calling the predicate.
322    pub fn remove_if(&self, remove: impl FnOnce(&[u8]) -> bool) -> io::Result<bool> {
323        let _lock = self.lock()?;
324        let Some(current) = read_regular(&self.0, self.1.read_limit)? else {
325            return Ok(false);
326        };
327        if !remove(&current) {
328            return Ok(false);
329        }
330        std::fs::remove_file(&self.0)?;
331        Ok(true)
332    }
333
334    /// Read the record without the slot lock: publication is atomic, so this
335    /// sees one complete version. `None` when there is none. A record that
336    /// is not a regular file, a symlink included, or that is longer than the
337    /// read limit is `InvalidData`.
338    ///
339    /// On Windows an open that races a replacement can be refused, or find
340    /// no record while the rename is in progress. A refused open is retried a
341    /// few times. A missing record is waited for, up to 2 s, while a
342    /// temporary file of this record created in the last 10 s shows a
343    /// publication in progress; otherwise it is `None` at once.
344    pub fn read(&self) -> io::Result<Option<Vec<u8>>> {
345        read_regular(&self.0, self.1.read_limit)
346    }
347
348    fn matches(&self, contents: &[u8]) -> io::Result<bool> {
349        let file = match open_record(OpenOptions::new().read(true), &self.0) {
350            Ok(file) => file,
351            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
352            Err(error) => return Err(error),
353        };
354        let mut current = Vec::new();
355        file.take(contents.len().saturating_add(1) as u64)
356            .read_to_end(&mut current)?;
357        Ok(current == contents)
358    }
359}