Skip to main content

git_policy_cli/
journald_native.rs

1//! What:
2//!  The service's way into the journal: the native journal protocol over a datagram socket when the service's standard
3//!  error is the journal, and a plain line on standard error otherwise.
4//! Why:
5//!  A line on a journal stream socket is stamped by journald when it processes it, so under load the times are seconds
6//!  late and out of order with the service manager's own lines. A datagram on the native socket carries the kernel's
7//!  time from the moment it was sent (measured in `doc/handover/cli-git-logging.md`), and the entry can carry a
8//!  priority and fields a reader filters on (`journalctl CLI_GIT_REQUEST_ID=<id>`). The protocol is at
9//!  <https://systemd.io/JOURNAL_NATIVE_PROTOCOL/>, and `$JOURNAL_STREAM` is described in `systemd.exec(5)`.
10//!  No lock is taken: the socket is unconnected and non-blocking, one `sendto` per entry, and an entry that cannot be
11//!  sent (journald restarting, a full queue) goes to standard error instead, so a commit never waits for the journal.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! if (sameFile(process.env.JOURNAL_STREAM, stderr)) socket.send(`PRIORITY=6\nMESSAGE=${line}\n`); else console.error(line);
16//! ```
17
18use std::io::Write;
19use std::os::linux::net::SocketAddrExt;
20use std::os::unix::net::{SocketAddr, UnixDatagram};
21
22/// The socket journald reads native entries from.
23pub const JOURNAL_SOCKET: &str = "/run/systemd/journal/socket";
24
25/// Test-only: names another socket to send entries to, as a path or, after an `@`, as an abstract name (a fixture path
26/// can be longer than a socket path may be). Detection by `$JOURNAL_STREAM` still applies, so a test points the
27/// variable at the file it gave the process as standard error.
28pub const TEST_SOCKET_VARIABLE: &str = "CLI_GIT_TEST_ONLY_JOURNAL_SOCKET";
29
30/// The variable the service manager sets to the device and inode of the stream it connected to the journal.
31pub const STREAM_VARIABLE: &str = "JOURNAL_STREAM";
32
33/// The `SYSLOG_IDENTIFIER` of every entry, the name `journalctl` shows after the host.
34pub const IDENTIFIER: &str = "cli-git-service";
35
36/// The most characters of a message an entry carries; longer text is cut. The limits count characters, not encoded bytes,
37/// and bound no total, so an entry made of very many or very wide fields can still exceed what a socket takes; the send then
38/// fails with `EMSGSIZE` and the entry goes to standard error, as every entry that cannot be sent does.
39pub const MESSAGE_LIMIT: usize = 16 * 1024;
40
41/// The most characters of a field value an entry carries.
42pub const FIELD_LIMIT: usize = 1024;
43
44/// What is appended to a cut message.
45const CUT_MARK: &str = " [cut]";
46
47/// How serious an entry is, as journald's `PRIORITY` (syslog levels).
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum Priority {
50    /// Something the service needed failed; `journalctl --priority=err` shows it.
51    Error,
52    /// Something did not go as intended and the service went on.
53    Warning,
54    /// An event of the service's ordinary life.
55    Info,
56}
57
58impl Priority {
59    /// The syslog number journald reads.
60    pub fn number(self) -> u8 {
61        return match self {
62            Priority::Error => 3,
63            Priority::Warning => 4,
64            Priority::Info => 6,
65        };
66    }
67}
68
69/// What:
70///  The text with every control character (a newline included) replaced by a space, and cut to `limit` characters.
71/// Why:
72///   One entry is one line whatever a client or an error message held: a newline in a message could otherwise start a
73///  field of its own in the datagram, or a second line in the standard error stream.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// text.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, limit)
78/// ```
79pub fn one_line(text: &str, limit: usize) -> String {
80    let mut single: String = String::with_capacity(text.len().min(limit + CUT_MARK.len()));
81    for (count, character) in text.chars().enumerate() {
82        if count >= limit {
83            single.push_str(CUT_MARK);
84            break;
85        }
86        single.push(if character.is_control() { ' ' } else { character });
87    }
88    return single;
89}
90
91/// Whether a field name is one journald accepts from a client: upper-case letters, digits and underscores, not starting
92/// with an underscore (those are the journal's own) or a digit.
93pub fn valid_field_name(name: &str) -> bool {
94    let mut characters = name.chars();
95    return characters.next().is_some_and(|first| { return first.is_ascii_uppercase(); })
96        && characters.all(|character| { return character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'; });
97}
98
99/// What:
100///  The bytes of one native entry: `PRIORITY`, `SYSLOG_IDENTIFIER`, `MESSAGE`, then each field as `NAME=value` and a newline.
101/// Why:
102///   Every value is a single line (`one_line`), so the simple form of the protocol is enough and no value can start a
103///  field. A field whose name journald would refuse is left out.
104///
105/// In TS you'd write (pseudocode):
106/// ```ts
107/// `PRIORITY=${n}\nSYSLOG_IDENTIFIER=cli-git-service\nMESSAGE=${message}\n${fields.map(([k, v]) => `${k}=${v}\n`).join('')}`
108/// ```
109pub fn datagram(priority: Priority, message: &str, fields: &[(&str, String)]) -> Vec<u8> {
110    let mut text: String = format!("PRIORITY={}\nSYSLOG_IDENTIFIER={IDENTIFIER}\nMESSAGE={}\n", priority.number(), one_line(message, MESSAGE_LIMIT));
111    for (name, value) in fields {
112        if valid_field_name(name) {
113            text.push_str(&format!("{name}={}\n", one_line(value, FIELD_LIMIT)));
114        }
115    }
116    return text.into_bytes();
117}
118
119/// Whether `$JOURNAL_STREAM` names the file with this device and inode, which is how `systemd.exec(5)` says to tell that
120/// standard error is still the journal: the variable alone is not enough, because a redirection leaves it set.
121pub fn stream_matches(variable: Option<&str>, device: u64, inode: u64) -> bool {
122    let Some((named_device, named_inode)) = variable.and_then(|text| { return text.split_once(':'); }) else {
123        return false;
124    };
125    return named_device.parse::<u64>().ok() == Some(device) && named_inode.parse::<u64>().ok() == Some(inode);
126}
127
128/// The device and inode of standard error, or nothing when it cannot be read.
129pub fn stderr_identity() -> Option<(u64, u64)> {
130    let mut status: libc::stat = unsafe { std::mem::zeroed() };
131    // SAFETY: `status` is valid for the call to write one `stat`.
132    let result: libc::c_int = unsafe { libc::fstat(libc::STDERR_FILENO, &raw mut status) };
133    if result != 0 {
134        return None;
135    }
136    return Some((u64::from(status.st_dev), u64::from(status.st_ino)));
137}
138
139/// What:
140///  The address as the C structure `sendto` takes, and its length, or nothing when the name does not fit.
141/// Why:
142///   A signal handler may not build it (the standard library allocates), so it is made once, ahead of the signal.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// rawAddress(address) // { family: AF_UNIX, path: '\0name' }
147/// ```
148pub fn raw_address(address: &SocketAddr) -> Option<(libc::sockaddr_un, libc::socklen_t)> {
149    use std::os::unix::ffi::OsStrExt;
150    // SAFETY: `sockaddr_un` is a plain C structure for which all zero bytes is a valid value.
151    let mut raw: libc::sockaddr_un = unsafe { std::mem::zeroed() };
152    raw.sun_family = libc::sa_family_t::try_from(libc::AF_UNIX).ok()?;
153    let (offset, name): (usize, &[u8]) = match (address.as_pathname(), address.as_abstract_name()) {
154        (Some(path), _) => (0, path.as_os_str().as_bytes()),
155        (None, Some(name)) => (1, name),
156        (None, None) => return None,
157    };
158    // A path needs room for the zero byte that ends it; an abstract name starts with one, so it may fill the whole field.
159    if offset + name.len() + usize::from(offset == 0) > raw.sun_path.len() {
160        return None;
161    }
162    for (slot, byte) in raw.sun_path[offset..].iter_mut().zip(name) {
163        *slot = libc::c_char::from_ne_bytes([*byte]);
164    }
165    let length: usize = std::mem::size_of::<libc::sa_family_t>() + offset + name.len() + usize::from(offset == 0);
166    return Some((raw, libc::socklen_t::try_from(length).ok()?));
167}
168
169/// Where an entry went.
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub enum Delivery {
172    /// As a datagram on the native socket.
173    Native,
174    /// As a line on standard error.
175    Stderr,
176}
177
178/// Where the journal listens.
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub enum Target {
181    /// A socket file.
182    Path(String),
183    /// An abstract name, which a test uses.
184    Abstract(String),
185}
186
187impl Target {
188    /// The journal's own socket.
189    pub fn journal() -> Self {
190        return Self::Path(String::from(JOURNAL_SOCKET));
191    }
192
193    /// Reads a test-only setting: `@name` is abstract, anything else a path.
194    pub fn parse(text: &str) -> Self {
195        return text.strip_prefix('@').map_or_else(|| { return Self::Path(text.to_string()); }, |name| { return Self::Abstract(name.to_string()); });
196    }
197
198    /// The socket address, or nothing when the name does not fit one.
199    pub fn address(&self) -> Option<SocketAddr> {
200        return match self {
201            Self::Path(path) => SocketAddr::from_pathname(path).ok(),
202            Self::Abstract(name) => SocketAddr::from_abstract_name(name.as_bytes()).ok(),
203        };
204    }
205}
206
207/// Where the service's entries go, decided once.
208#[derive(Debug)]
209pub struct Sink {
210    /// The unconnected, non-blocking socket, when standard error is the journal and a socket could be made.
211    native: Option<UnixDatagram>,
212    /// The journal's socket.
213    address: Option<SocketAddr>,
214    /// Whether standard error is the journal's stream, so that a line there may carry a `<priority>` prefix.
215    stream: bool,
216}
217
218impl Sink {
219    /// What:
220    ///  The sink for a process whose `$JOURNAL_STREAM` is `variable`, whose standard error is the file `stderr`, and
221    ///  whose journal socket is at `socket`.
222    /// Why:
223    ///   The native socket is used only when standard error is the journal; a terminal, a file or a pipe keeps its plain
224    ///  lines, and a service run by hand reads as before.
225    ///
226    /// In TS you'd write (pseudocode):
227    /// ```ts
228    /// Sink.choose(process.env.JOURNAL_STREAM, stderrIdentity(), '/run/systemd/journal/socket')
229    /// ```
230    pub fn choose(variable: Option<&str>, stderr: Option<(u64, u64)>, socket: &Target) -> Self {
231        let stream: bool = stderr.is_some_and(|(device, inode)| { return stream_matches(variable, device, inode); });
232        let native: Option<UnixDatagram> = if stream { Self::open() } else { None };
233        return Self { native, address: socket.address(), stream };
234    }
235
236    /// An unconnected datagram socket that never blocks, or nothing.
237    fn open() -> Option<UnixDatagram> {
238        let socket: UnixDatagram = UnixDatagram::unbound().ok()?;
239        socket.set_nonblocking(true).ok()?;
240        return Some(socket);
241    }
242
243    /// The sink of this process: the environment's `$JOURNAL_STREAM`, the real standard error, and the real socket or the
244    /// test-only one.
245    pub fn from_environment() -> Self {
246        let variable: Option<String> = std::env::var(STREAM_VARIABLE).ok();
247        let socket: Target = std::env::var(TEST_SOCKET_VARIABLE).map_or_else(|_| { return Target::journal(); }, |text| { return Target::parse(text.as_str()); });
248        return Self::choose(variable.as_deref(), stderr_identity(), &socket);
249    }
250
251    /// Whether standard error is the journal's stream, so that a write there may carry a `<priority>` prefix and must not wait.
252    pub fn on_stream(&self) -> bool {
253        return self.stream;
254    }
255
256    /// Whether this sink sends datagrams.
257    pub fn is_native(&self) -> bool {
258        return self.native.is_some();
259    }
260
261    /// The raw descriptor and socket address a signal handler sends its entry with, when this sink is native.
262    pub fn signal_target(&self) -> Option<(std::os::fd::RawFd, &SocketAddr)> {
263        use std::os::fd::AsRawFd;
264        let socket: &UnixDatagram = self.native.as_ref()?;
265        return self.address.as_ref().map(|address| { return (socket.as_raw_fd(), address); });
266    }
267
268    /// What:
269    ///  Sends one entry, and gives where it went: a datagram when the sink is native and the socket takes it, otherwise
270    ///  `line` on `stderr`.
271    /// Why:
272    ///   A journal that is restarting, absent or full is no reason for the service to wait or to lose the entry. Standard
273    ///  error is the journal's stream when the sink was chosen for it, and a `<priority>` prefix there keeps the level.
274    ///
275    /// In TS you'd write (pseudocode):
276    /// ```ts
277    /// try { socket.send(datagram) } catch { stderr.write(prefix + line) }
278    /// ```
279    pub fn deliver(&self, priority: Priority, message: &str, fields: &[(&str, String)], line: &str, stderr: &mut dyn Write) -> Delivery {
280        if let Some(socket) = self.native.as_ref()
281            && let Some(address) = self.address.as_ref()
282            && socket.send_to_addr(datagram(priority, message, fields).as_slice(), address).is_ok()
283        {
284            return Delivery::Native;
285        }
286        let prefix: String = if self.stream { format!("<{}>", priority.number()) } else { String::new() };
287        // The result is dropped on purpose: a closed standard error is no reason to fail the work the entry describes.
288        let _written = stderr.write_all(format!("{prefix}{line}").as_bytes());
289        return Delivery::Stderr;
290    }
291}
292
293/// What:
294///  A writer to the journal's stream (standard error as the service manager connected it) that never waits for room.
295/// Why:
296///   `O_NONBLOCK` on the datagram socket does not make standard error non-blocking, and a journal that stops reading would
297///  fill the stream and block the service, and so the commits that wait for it. Each line is sent with `MSG_DONTWAIT`: a
298///  line that finds no room at all is dropped whole. A line the socket took only in part is finished within a short bound
299///  (three waits of at most 100 milliseconds), because the stream would otherwise carry half a line into the next one.
300///
301/// In TS you'd write (pseudocode):
302/// ```ts
303/// try { socket.sendNonBlocking(line) } catch (error) { if (error.code === 'EAGAIN') drop(line) }
304/// ```
305#[derive(Clone, Copy, Debug)]
306pub struct StreamWriter {
307    /// The descriptor of the stream.
308    descriptor: std::os::fd::RawFd,
309}
310
311impl StreamWriter {
312    /// A writer to standard error.
313    pub fn stderr() -> Self {
314        return Self { descriptor: libc::STDERR_FILENO };
315    }
316
317    /// A writer to a stream socket descriptor.
318    pub fn on(descriptor: std::os::fd::RawFd) -> Self {
319        return Self { descriptor };
320    }
321
322    /// Sends bytes without waiting; the count sent, or the error.
323    fn send_now(&self, bytes: &[u8]) -> std::io::Result<usize> {
324        // SAFETY: `bytes` is valid for its length for the call.
325        let sent: isize = unsafe { libc::send(self.descriptor, bytes.as_ptr().cast(), bytes.len(), libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL) };
326        let Err(error) = usize::try_from(sent).map_err(|_| { return std::io::Error::last_os_error(); }) else {
327            return Ok(usize::try_from(sent).unwrap_or(0));
328        };
329        if error.raw_os_error() != Some(libc::ENOTSOCK) {
330            return Err(error);
331        }
332        // Not a socket (a file or a pipe a test or a redirection put there): an ordinary write.
333        // SAFETY: `bytes` is valid for its length for the call.
334        let written: isize = unsafe { libc::write(self.descriptor, bytes.as_ptr().cast(), bytes.len()) };
335        return usize::try_from(written).map_err(|_| { return std::io::Error::last_os_error(); });
336    }
337
338    /// Waits at most 100 milliseconds for room.
339    fn wait_for_room(&self) {
340        let mut request: libc::pollfd = libc::pollfd { fd: self.descriptor, events: libc::POLLOUT, revents: 0 };
341        // SAFETY: `request` is valid for the call, which reads and writes one `pollfd`.
342        unsafe { libc::poll(&raw mut request, 1, 100) };
343    }
344}
345
346impl Write for StreamWriter {
347    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
348        return self.send_now(bytes);
349    }
350
351    fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
352        let mut rest: &[u8] = bytes;
353        let mut waits: u32 = 0;
354        while !rest.is_empty() {
355            match self.send_now(rest) {
356                Ok(count) => rest = &rest[count..],
357                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && rest.len() == bytes.len() => return Err(error),
358                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && waits < 3 => {
359                    waits += 1;
360                    self.wait_for_room();
361                }
362                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
363                Err(error) => return Err(error),
364            }
365        }
366        return Ok(());
367    }
368
369    fn flush(&mut self) -> std::io::Result<()> {
370        return Ok(());
371    }
372}
373
374/// The datagram, the detection, the fallback.
375#[cfg(test)]
376#[path = "journald_native_tests.rs"]
377mod tests;