Skip to main content

zlayer_paths/
lib.rs

1use std::path::{Path, PathBuf};
2
3pub mod escalate;
4pub mod safe_fs;
5
6pub use escalate::{
7    applescript_quote, root_command, run_as_root, shell_quote, EscalationError, RootCommand,
8};
9
10/// Centralized filesystem path resolution for ZLayer.
11///
12/// All ZLayer crates should use this instead of hardcoding paths.
13pub struct ZLayerDirs {
14    data_dir: PathBuf,
15}
16
17impl ZLayerDirs {
18    /// Create from an explicit data directory.
19    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
20        Self {
21            data_dir: data_dir.into(),
22        }
23    }
24
25    /// Create using the platform default data directory.
26    pub fn system_default() -> Self {
27        Self::new(Self::default_data_dir())
28    }
29
30    // -- Platform defaults (associated functions) ----------------------------
31
32    /// Platform-aware default data directory.
33    ///
34    /// - `$ZLAYER_DATA_DIR` (if set and non-empty) overrides every other source.
35    /// - macOS: `~/.zlayer`
36    /// - Linux (root): `/var/lib/zlayer`
37    /// - Linux (user): `~/.zlayer`
38    /// - Windows: `%ProgramData%\ZLayer` (system) or `C:\ProgramData\ZLayer`
39    ///   fallback. HCS-backed nodes run as SYSTEM so the system-wide
40    ///   `ProgramData` location is the right default.
41    pub fn default_data_dir() -> PathBuf {
42        if let Some(env_dir) = std::env::var_os("ZLAYER_DATA_DIR") {
43            if !env_dir.is_empty() {
44                return PathBuf::from(env_dir);
45            }
46        }
47        platform_default_data_dir()
48    }
49
50    /// Detect the data directory of an existing installation.
51    ///
52    /// On Linux, if not root, checks whether `/var/lib/zlayer/daemon.json`
53    /// exists (indicating a system-level install) and returns
54    /// `/var/lib/zlayer` if so. On Windows, probes `%ProgramData%\ZLayer`
55    /// for a `daemon.json` marker in case the caller lacks the env var but
56    /// a prior system install is present. Otherwise falls back to
57    /// [`default_data_dir`].
58    pub fn detect_data_dir() -> PathBuf {
59        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
60        {
61            if !is_root() {
62                let system_data = PathBuf::from("/var/lib/zlayer");
63                if system_data.join("daemon.json").exists() {
64                    return system_data;
65                }
66            }
67        }
68        #[cfg(target_os = "windows")]
69        {
70            let system_data = windows_program_data_root();
71            if system_data.join("daemon.json").exists() {
72                return system_data;
73            }
74        }
75        Self::default_data_dir()
76    }
77
78    /// Default runtime directory.
79    ///
80    /// - Linux: `/var/run/zlayer`
81    /// - macOS: `{default_data_dir}/run`
82    /// - Windows: `{default_data_dir}\run` (i.e. `%ProgramData%\ZLayer\run`)
83    pub fn default_run_dir() -> PathBuf {
84        Self::default_run_dir_for(&Self::default_data_dir())
85    }
86
87    /// Data-dir-aware default run directory.
88    ///
89    /// Returns the platform's system default (e.g. `/var/run/zlayer` on Linux)
90    /// when `data_dir` matches [`Self::default_data_dir`]; otherwise returns
91    /// `{data_dir}/run`. This preserves the FHS layout for stock installs while
92    /// letting `--data-dir /tmp/foo` get a fully isolated runtime directory.
93    pub fn default_run_dir_for(data_dir: &Path) -> PathBuf {
94        let system_default = fhs_system_data_dir();
95        if data_dir == system_default.as_path() {
96            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
97            {
98                return PathBuf::from("/var/run/zlayer");
99            }
100            #[cfg(any(target_os = "macos", target_os = "windows"))]
101            {
102                return system_default.join("run");
103            }
104        }
105        data_dir.join("run")
106    }
107
108    /// Default log directory.
109    ///
110    /// - Linux: `/var/log/zlayer`
111    /// - macOS: `{default_data_dir}/logs`
112    /// - Windows: `{default_data_dir}\logs` (i.e. `%ProgramData%\ZLayer\logs`)
113    pub fn default_log_dir() -> PathBuf {
114        Self::default_log_dir_for(&Self::default_data_dir())
115    }
116
117    /// Data-dir-aware default log directory.
118    ///
119    /// Returns the platform's system default (e.g. `/var/log/zlayer` on Linux)
120    /// when `data_dir` matches [`Self::default_data_dir`]; otherwise returns
121    /// `{data_dir}/logs`. This preserves the FHS layout for stock installs
122    /// while letting `--data-dir /tmp/foo` get a fully isolated log directory.
123    pub fn default_log_dir_for(data_dir: &Path) -> PathBuf {
124        let system_default = fhs_system_data_dir();
125        if data_dir == system_default.as_path() {
126            #[cfg(not(any(target_os = "macos", target_os = "windows")))]
127            {
128                return PathBuf::from("/var/log/zlayer");
129            }
130            #[cfg(any(target_os = "macos", target_os = "windows"))]
131            {
132                return system_default.join("logs");
133            }
134        }
135        data_dir.join("logs")
136    }
137
138    /// Default Unix socket path.
139    ///
140    /// - Linux: `/var/run/zlayer.sock`
141    /// - macOS: `{default_data_dir}/run/zlayer.sock`
142    /// - Windows: `tcp://127.0.0.1:3669`
143    pub fn default_socket_path() -> String {
144        Self::default_socket_path_for(&Self::default_data_dir())
145    }
146
147    /// Data-dir-aware default daemon socket path.
148    ///
149    /// On Windows always returns `tcp://127.0.0.1:3669` regardless of
150    /// `data_dir` (the daemon listens on TCP loopback, not a filesystem
151    /// socket). On Unix, returns the platform's system default when
152    /// `data_dir` matches [`Self::default_data_dir`]; otherwise returns
153    /// `{data_dir}/run/zlayer.sock`. Stock installs keep their FHS-style
154    /// path while `--data-dir /tmp/foo` gets an isolated socket.
155    pub fn default_socket_path_for(data_dir: &Path) -> String {
156        #[cfg(target_os = "windows")]
157        {
158            let _ = data_dir;
159            "tcp://127.0.0.1:3669".to_string()
160        }
161        #[cfg(not(target_os = "windows"))]
162        {
163            let system_default = fhs_system_data_dir();
164            if data_dir == system_default.as_path() {
165                #[cfg(target_os = "macos")]
166                {
167                    return system_default
168                        .join("run")
169                        .join("zlayer.sock")
170                        .to_string_lossy()
171                        .into_owned();
172                }
173                #[cfg(not(target_os = "macos"))]
174                {
175                    return "/var/run/zlayer.sock".to_string();
176                }
177            }
178            let natural = data_dir
179                .join("run")
180                .join("zlayer.sock")
181                .to_string_lossy()
182                .into_owned();
183            if natural.len() <= SUN_PATH_MAX {
184                natural
185            } else {
186                socket_safe_fallback(data_dir, "daemon")
187            }
188        }
189    }
190
191    /// Data-dir-aware default `zlayer-overlayd` IPC socket path.
192    ///
193    /// `zlayer-overlayd` is the standalone overlay daemon; the main daemon
194    /// drives it over this endpoint. Mirrors [`Self::default_socket_path_for`]:
195    ///
196    /// - Windows: always `\\.\pipe\zlayer-overlayd` (named pipe, not a file).
197    /// - Unix, default data dir: `/var/run/zlayer-overlayd.sock`.
198    /// - Unix, overridden data dir: `{data_dir}/run/zlayer-overlayd.sock`
199    ///   (falling back to a length-safe path if that would exceed `SUN_PATH`).
200    pub fn default_overlayd_socket_path_for(data_dir: &Path) -> String {
201        #[cfg(target_os = "windows")]
202        {
203            let _ = data_dir;
204            r"\\.\pipe\zlayer-overlayd".to_string()
205        }
206        #[cfg(not(target_os = "windows"))]
207        {
208            let system_default = platform_default_data_dir();
209            if data_dir == system_default.as_path() {
210                #[cfg(target_os = "macos")]
211                {
212                    return system_default
213                        .join("run")
214                        .join("zlayer-overlayd.sock")
215                        .to_string_lossy()
216                        .into_owned();
217                }
218                #[cfg(not(target_os = "macos"))]
219                {
220                    return "/var/run/zlayer-overlayd.sock".to_string();
221                }
222            }
223            let natural = data_dir
224                .join("run")
225                .join("zlayer-overlayd.sock")
226                .to_string_lossy()
227                .into_owned();
228            if natural.len() <= SUN_PATH_MAX {
229                natural
230            } else {
231                socket_safe_fallback(data_dir, "overlayd")
232            }
233        }
234    }
235
236    /// Default Docker-compatible API socket path.
237    ///
238    /// - Linux (root): `/var/run/zlayer/docker.sock`
239    /// - Linux (user, `XDG_RUNTIME_DIR` set): `{XDG_RUNTIME_DIR}/zlayer/docker.sock`
240    /// - Linux (user, no `XDG_RUNTIME_DIR`): `{default_data_dir}/run/docker.sock`
241    /// - macOS: `{default_data_dir}/run/docker.sock`
242    /// - Windows: `\\.\pipe\zlayer-docker`
243    pub fn default_docker_socket_path() -> String {
244        #[cfg(target_os = "windows")]
245        {
246            r"\\.\pipe\zlayer-docker".to_string()
247        }
248        #[cfg(not(target_os = "windows"))]
249        {
250            #[cfg(target_os = "macos")]
251            {
252                let path = Self::default_data_dir()
253                    .join("run")
254                    .join("docker.sock")
255                    .to_string_lossy()
256                    .into_owned();
257                if path.len() <= SUN_PATH_MAX {
258                    path
259                } else {
260                    socket_safe_fallback(&Self::default_data_dir(), "docker")
261                }
262            }
263            #[cfg(not(target_os = "macos"))]
264            {
265                if is_root() {
266                    "/var/run/zlayer/docker.sock".to_string()
267                } else if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
268                    let xdg_path = PathBuf::from(&xdg);
269                    let mut p = xdg_path.clone();
270                    p.push("zlayer");
271                    p.push("docker.sock");
272                    let path = p.to_string_lossy().into_owned();
273                    if path.len() <= SUN_PATH_MAX {
274                        path
275                    } else {
276                        socket_safe_fallback(&xdg_path, "docker")
277                    }
278                } else {
279                    let path = Self::default_data_dir()
280                        .join("run")
281                        .join("docker.sock")
282                        .to_string_lossy()
283                        .into_owned();
284                    if path.len() <= SUN_PATH_MAX {
285                        path
286                    } else {
287                        socket_safe_fallback(&Self::default_data_dir(), "docker")
288                    }
289                }
290            }
291        }
292    }
293
294    /// Preferred system directory for the `zlayer` binary.
295    ///
296    /// Tries `/usr/local/bin` first (standard FHS, writable on most systems).
297    /// Falls back to `{data_dir}/bin` (`/var/lib/zlayer/bin` on Linux as root)
298    /// which is always writable since `ZLayer` owns that directory.
299    ///
300    /// On macOS and Windows, returns `/usr/local/bin` or the data-dir `bin`
301    /// subdirectory respectively.
302    pub fn default_binary_dir() -> PathBuf {
303        // Probe /usr/local/bin writability — metadata mode bits lie on overlayfs
304        #[cfg(unix)]
305        {
306            let probe = PathBuf::from("/usr/local/bin/.zlayer_write_probe");
307            if std::fs::write(&probe, b"").is_ok() {
308                let _ = std::fs::remove_file(&probe);
309                return PathBuf::from("/usr/local/bin");
310            }
311        }
312        // Fallback: our own bin dir (always writable)
313        let dirs = Self::system_default();
314        let bin_dir = dirs.bin();
315        let _ = std::fs::create_dir_all(&bin_dir);
316        bin_dir
317    }
318
319    // -- Core subdirectories -------------------------------------------------
320
321    /// Root data directory.
322    pub fn data_dir(&self) -> &Path {
323        &self.data_dir
324    }
325
326    /// Container state directory (`{data}/containers`).
327    pub fn containers(&self) -> PathBuf {
328        self.data_dir.join("containers")
329    }
330
331    /// Unpacked image rootfs directory (`{data}/rootfs`).
332    pub fn rootfs(&self) -> PathBuf {
333        self.data_dir.join("rootfs")
334    }
335
336    /// OCI bundle directory (`{data}/bundles`).
337    pub fn bundles(&self) -> PathBuf {
338        self.data_dir.join("bundles")
339    }
340
341    /// Shared per-layer overlay lowerdir store (`{data}/layers`).
342    ///
343    /// Each extracted image layer lives at `{data}/layers/<sanitize_digest>/fs`
344    /// and is reused as an overlayfs `lowerdir` across every container of every
345    /// image that references that layer (the overlayfs-rootfs feature), instead
346    /// of being copied into each container's rootfs.
347    ///
348    /// MUST be on the SAME filesystem as [`bundles`](Self::bundles) — both live
349    /// under `data_dir`, so they are — because an overlay mount requires its
350    /// `upperdir`/`workdir` (under the bundle) and `lowerdir`s (here) to share a
351    /// filesystem, and because [`crate::safe_fs`] rename-based atomic publish of
352    /// a freshly extracted layer must be a same-fs `rename(2)`, never a copy.
353    pub fn layer_store(&self) -> PathBuf {
354        self.data_dir.join("layers")
355    }
356
357    /// Image/blob cache directory (`{data}/cache`).
358    pub fn cache(&self) -> PathBuf {
359        self.data_dir.join("cache")
360    }
361
362    /// Named volumes directory (`{data}/volumes`).
363    pub fn volumes(&self) -> PathBuf {
364        self.data_dir.join("volumes")
365    }
366
367    /// Project git clones directory (`{data}/projects`). Persistent state —
368    /// per-project working copies live at `{data}/projects/{project_id}`.
369    pub fn projects(&self) -> PathBuf {
370        self.data_dir.join("projects")
371    }
372
373    /// WASM module cache directory (`{data}/wasm`).
374    pub fn wasm(&self) -> PathBuf {
375        self.data_dir.join("wasm")
376    }
377
378    /// AOT-compiled WASM cache directory (`{data}/wasm/compiled`).
379    pub fn wasm_compiled(&self) -> PathBuf {
380        self.data_dir.join("wasm").join("compiled")
381    }
382
383    /// Encrypted secrets store directory (`{data}/secrets`).
384    pub fn secrets(&self) -> PathBuf {
385        self.data_dir.join("secrets")
386    }
387
388    /// TLS certificate storage directory (`{data}/certs`).
389    pub fn certs(&self) -> PathBuf {
390        self.data_dir.join("certs")
391    }
392
393    /// Raft consensus data directory (`{data}/raft`).
394    pub fn raft(&self) -> PathBuf {
395        self.data_dir.join("raft")
396    }
397
398    /// Admin password file path (`{data}/admin_password`).
399    pub fn admin_password(&self) -> PathBuf {
400        self.data_dir.join("admin_password")
401    }
402
403    /// Path to the persisted local-admin bearer token file.
404    ///
405    /// On Linux/macOS this file is informational — the daemon's UDS middleware
406    /// already injects the bearer into UDS-originated requests. On Windows the
407    /// `DaemonClient` reads this file on connect to authenticate against the
408    /// loopback TCP listener (which has no socket-path-based local-admin
409    /// bypass).
410    ///
411    /// Default: `<data_dir>/admin_bearer.token`
412    ///
413    /// On Windows this resolves under `%ProgramData%\ZLayer` so the file
414    /// inherits the parent ACL (SYSTEM + Administrators write, Users read),
415    /// which is adequate for the local-admin bearer.
416    #[must_use]
417    pub fn admin_bearer_path(&self) -> PathBuf {
418        self.data_dir.join("admin_bearer.token")
419    }
420
421    /// Daemon metadata file path (`{data}/daemon.json`).
422    pub fn daemon_json(&self) -> PathBuf {
423        self.data_dir.join("daemon.json")
424    }
425
426    /// Path to the agent's local IPAM (per-node slice allocator) state file.
427    pub fn agent_ipam_state(&self) -> PathBuf {
428        self.data_dir.join("agent_ipam.json")
429    }
430
431    /// Path to the agent's managed-network marker file
432    /// (`{data}/agent_network.json`).
433    ///
434    /// Records the host-level networks ZLayer creates (e.g. the Windows HCN
435    /// overlay network) so they can be reused across daemon restarts/updates
436    /// and torn down **only** on a full uninstall (`daemon uninstall --purge`),
437    /// not on every restart/reinstall.
438    pub fn agent_network_state(&self) -> PathBuf {
439        self.data_dir.join("agent_network.json")
440    }
441
442    /// Logs subdirectory under data_dir (`{data}/logs`).
443    /// Used on macOS where logs live under the user data dir.
444    pub fn logs(&self) -> PathBuf {
445        self.data_dir.join("logs")
446    }
447
448    // -- macOS sandbox / builder paths ---------------------------------------
449
450    /// macOS VM state directory (`{data}/vms`).
451    pub fn vms(&self) -> PathBuf {
452        self.data_dir.join("vms")
453    }
454
455    /// OCI image storage directory (`{data}/images`).
456    pub fn images(&self) -> PathBuf {
457        self.data_dir.join("images")
458    }
459
460    /// Local binary directory (`{data}/bin`).
461    pub fn bin(&self) -> PathBuf {
462        self.data_dir.join("bin")
463    }
464
465    /// Canonical install path for the `zlayer-buildd` sidecar binary:
466    /// `{data}/bin/zlayer-buildd`. Resolved by the buildah-sidecar
467    /// backend's discovery logic and written by the `zlayer install
468    /// --sidecar` installer.
469    #[must_use]
470    pub fn buildd_bin(&self) -> PathBuf {
471        self.bin().join("zlayer-buildd")
472    }
473
474    /// Directory holding sidecar mTLS material: `{data}/buildd`. Contains
475    /// `ca.pem`, `cert.pem`, and `key.pem` consumed by both ends of the
476    /// `zlayer-buildd` gRPC channel.
477    #[must_use]
478    pub fn buildd(&self) -> PathBuf {
479        self.data_dir.join("buildd")
480    }
481
482    /// Toolchain download cache directory (`{data}/toolchain-cache`).
483    ///
484    /// This is the `RUNNER_TOOL_CACHE` clone source — the multi-arch language
485    /// toolchains (go/node/rust/...) staged for jobs to consume. It is a
486    /// DIFFERENT directory from [`host_toolchain_cache`](Self::host_toolchain_cache).
487    pub fn toolchain_cache(&self) -> PathBuf {
488        self.data_dir.join("toolchain-cache")
489    }
490
491    /// Host init-toolchain keg cache (`{data}/host-toolchains`).
492    ///
493    /// The macOS Seatbelt sandbox provisions the INIT toolchains (`git` +
494    /// `node@lts`) here at container start and injects their kegs onto the
495    /// container PATH so node20 JS actions (`node dist/index.js`) resolve. This
496    /// is deliberately SEPARATE from [`toolchain_cache`](Self::toolchain_cache)
497    /// (the `RUNNER_TOOL_CACHE` source): the daemon looks for warmed init kegs
498    /// here and nowhere else, so `zlayer toolchains ensure` must provision into
499    /// this exact directory.
500    pub fn host_toolchain_cache(&self) -> PathBuf {
501        self.data_dir.join("host-toolchains")
502    }
503
504    /// Temporary build directory (`{data}/tmp`).
505    pub fn tmp(&self) -> PathBuf {
506        self.data_dir.join("tmp")
507    }
508
509    /// Create a uniquely-named scratch directory under `{data}/tmp`.
510    ///
511    /// Returns a [`zlayer_types::Scratch`] RAII guard — the directory is
512    /// removed when the guard is dropped. Use this instead of
513    /// `tempfile::tempdir()` so scratch data lives on the configured data
514    /// filesystem rather than `/tmp`, which is tmpfs (RAM-backed) on most
515    /// modern Linux distros and risks OOM for large scratch data
516    /// (build contexts, image tarballs, layer staging, etc.).
517    ///
518    /// # Errors
519    ///
520    /// Returns the underlying filesystem error if `{data}/tmp` can't be
521    /// created or the unique subdirectory can't be allocated.
522    pub fn scratch_dir(&self, prefix: &str) -> std::io::Result<zlayer_types::Scratch> {
523        std::fs::create_dir_all(self.tmp())?;
524        let td = tempfile::Builder::new()
525            .prefix(prefix)
526            .tempdir_in(self.tmp())?;
527        Ok(zlayer_types::Scratch::from_tempdir(td))
528    }
529
530    /// Create a uniquely-named scratch directory under an explicit `base`
531    /// directory (created if absent), rather than `{data}/tmp`.
532    ///
533    /// Used when the scratch data must live under a specific path — e.g. the
534    /// macOS `zlayer-buildd` shared context mount, so an in-guest buildah can
535    /// see a socket-delivered build context that would otherwise be extracted to
536    /// an unmounted data-dir scratch. Returns the same RAII [`zlayer_types::Scratch`]
537    /// guard as [`Self::scratch_dir`].
538    ///
539    /// # Errors
540    ///
541    /// Returns the underlying filesystem error if `base` can't be created or the
542    /// unique subdirectory can't be allocated.
543    pub fn scratch_dir_in(
544        base: &std::path::Path,
545        prefix: &str,
546    ) -> std::io::Result<zlayer_types::Scratch> {
547        std::fs::create_dir_all(base)?;
548        let td = tempfile::Builder::new().prefix(prefix).tempdir_in(base)?;
549        Ok(zlayer_types::Scratch::from_tempdir(td))
550    }
551
552    /// Create a uniquely-named scratch file under `{data}/tmp`.
553    ///
554    /// Returns a [`zlayer_types::ScratchFile`] RAII guard. Same rationale
555    /// as [`Self::scratch_dir`].
556    ///
557    /// # Errors
558    ///
559    /// Returns the underlying filesystem error if `{data}/tmp` can't be
560    /// created or the unique file can't be allocated.
561    pub fn scratch_file(&self, prefix: &str) -> std::io::Result<zlayer_types::ScratchFile> {
562        std::fs::create_dir_all(self.tmp())?;
563        let nf = tempfile::Builder::new()
564            .prefix(prefix)
565            .tempfile_in(self.tmp())?;
566        Ok(zlayer_types::ScratchFile::from_named(nf))
567    }
568
569    /// Data-dir-aware `WireGuard` UAPI socket directory.
570    ///
571    /// When `data_dir == Self::default_data_dir()`, returns
572    /// `/var/run/wireguard` (FHS default — also where `wg(8)` looks).
573    /// Otherwise returns `{data_dir}/run/wireguard` so an isolated
574    /// install (e.g. `--data-dir /tmp/foo`) does not collide with a
575    /// system install on the same host.
576    ///
577    /// macOS / Windows: always returns `{data_dir}/run/wireguard`
578    /// since the FHS path doesn't apply.
579    pub fn wireguard(&self) -> PathBuf {
580        #[cfg(any(target_os = "macos", target_os = "windows"))]
581        let natural = self.data_dir.join("run").join("wireguard");
582        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
583        let natural = if self.data_dir == Self::default_data_dir() {
584            PathBuf::from("/var/run/wireguard")
585        } else {
586            self.data_dir.join("run").join("wireguard")
587        };
588        // 1 byte separator + IFNAMSIZ (15) + ".sock" (5) = 21
589        const WG_DIR_MAX: usize = SUN_PATH_MAX - 21;
590        if natural.to_string_lossy().len() <= WG_DIR_MAX {
591            natural
592        } else {
593            PathBuf::from(format!(
594                "/tmp/zlayer-wg-{:016x}",
595                hash_for_socket(&self.data_dir, "wg")
596            ))
597        }
598    }
599}
600
601/// Convenience: `ZLayerDirs::system_default().admin_bearer_path()`.
602#[must_use]
603pub fn default_admin_bearer_path() -> PathBuf {
604    ZLayerDirs::system_default().admin_bearer_path()
605}
606
607/// Sanitize an OCI digest into a single, filesystem-safe path component.
608///
609/// Replaces `:` and `/` with `_` so a digest like
610/// `sha256:abcd…` becomes `sha256_abcd…`. This MUST stay byte-identical to
611/// `blob_staging_filename` in `zlayer-registry`'s `client.rs`
612/// (`digest.replace([':', '/'], "_")`) — that function names the staged
613/// compressed-layer blob, and the overlay layer-store keys layers by the SAME
614/// sanitized compressed digest so the on-disk staging filename and the
615/// layer-store directory name line up. The rule is duplicated rather than
616/// shared because `zlayer-paths` is a leaf crate that `zlayer-registry` depends
617/// on (not vice-versa); keep the two in lockstep if either ever changes.
618#[must_use]
619pub fn sanitize_digest(digest: &str) -> String {
620    digest.replace([':', '/'], "_")
621}
622
623// -- Internal helpers --------------------------------------------------------
624
625/// Platform-default data directory, ignoring `$ZLAYER_DATA_DIR`.
626///
627/// Mirrors [`ZLayerDirs::default_data_dir`] but skips the env-var override so
628/// `default_run_dir_for` / `default_log_dir_for` / `default_socket_path_for`
629/// can compare a caller-supplied `data_dir` against the platform-hardcoded
630/// default rather than against whatever the env var currently resolves to
631/// (which would make both sides of the comparison identical when the env var
632/// is set, falsely triggering the FHS branch).
633pub(crate) fn platform_default_data_dir() -> PathBuf {
634    #[cfg(target_os = "macos")]
635    {
636        home_dir_or_fallback().join(".zlayer")
637    }
638    #[cfg(target_os = "windows")]
639    {
640        windows_program_data_root()
641    }
642    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
643    {
644        if is_root() {
645            PathBuf::from("/var/lib/zlayer")
646        } else {
647            home_dir_or_fallback().join(".zlayer")
648        }
649    }
650}
651
652/// The canonical SYSTEM-install data dir whose runtime/log/socket subpaths
653/// follow the FHS layout (`/var/run/zlayer`, `/var/log/zlayer`,
654/// `/var/run/zlayer.sock`), **independent of the current euid**.
655///
656/// This differs from [`platform_default_data_dir`], which is euid-dependent on
657/// Linux (`/var/lib/zlayer` for root, `~/.zlayer` for a non-root user). The FHS
658/// mapping is a property of the *system install path* `/var/lib/zlayer`, not of
659/// who is asking: a non-root user in the `zlayer` group controlling the system
660/// daemon (via [`detect_data_dir`], which can return `/var/lib/zlayer` for a
661/// non-root caller) must resolve the SAME `/var/run/zlayer.sock` the root daemon
662/// actually listens on — not a derived `/var/lib/zlayer/run/zlayer.sock`. The
663/// `*_for` helpers therefore key the FHS decision off this, not the euid-default.
664///
665/// On macOS/Windows there is no euid split, so this equals
666/// [`platform_default_data_dir`].
667fn fhs_system_data_dir() -> PathBuf {
668    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
669    {
670        PathBuf::from("/var/lib/zlayer")
671    }
672    #[cfg(any(target_os = "macos", target_os = "windows"))]
673    {
674        platform_default_data_dir()
675    }
676}
677
678/// Max usable bytes in `sockaddr_un.sun_path` across our supported Unix
679/// targets. Linux's `sun_path` is `char[108]`; macOS's is `char[104]`.
680/// Pick the lower bound and reserve one byte for the trailing NUL so the
681/// check is trivial on both platforms.
682const SUN_PATH_MAX: usize = 103;
683
684/// FNV-1a hash of `data_dir`'s bytes plus a label. Dependency-free and
685/// deterministic across processes (unlike `DefaultHasher` which is
686/// keyed per-process). The daemon and any CLI client passing the same
687/// `data_dir` resolve to byte-identical paths.
688fn hash_for_socket(data_dir: &Path, label: &str) -> u64 {
689    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
690    for b in data_dir.to_string_lossy().as_bytes() {
691        h ^= u64::from(*b);
692        h = h.wrapping_mul(0x0000_0100_0000_01b3);
693    }
694    for b in label.as_bytes() {
695        h ^= u64::from(*b);
696        h = h.wrapping_mul(0x0000_0100_0000_01b3);
697    }
698    h
699}
700
701/// Short, deterministic UDS path for callers whose natural
702/// `{data_dir}/run/...` path would overflow `sun_path`.
703///
704/// **Scope guardrail:** `/tmp` is acceptable here ONLY because a UDS
705/// endpoint file is just an inode (kernel metadata, no payload). Daemon
706/// state — databases, logs, blob caches, image rootfs, anything the
707/// daemon reads back later — must NEVER use `/tmp`, even as a fallback.
708/// Most modern Linux distros mount `/tmp` as tmpfs (RAM-backed) and
709/// silently landing state there courts OOM under load. Sockets are the
710/// narrow exception because the file itself stores ~256 bytes of inode
711/// metadata.
712#[cfg(not(target_os = "windows"))]
713fn socket_safe_fallback(data_dir: &Path, label: &str) -> String {
714    format!(
715        "/tmp/zlayer-{label}-{:016x}.sock",
716        hash_for_socket(data_dir, label)
717    )
718}
719
720#[cfg(not(target_os = "windows"))]
721fn home_dir_or_fallback() -> PathBuf {
722    // Falls back to the FHS system data dir rather than /tmp because /tmp is
723    // tmpfs (RAM-backed) on most modern Linux distros and silently landing
724    // daemon state there courts OOM.
725    std::env::var_os("HOME")
726        .map(PathBuf::from)
727        .unwrap_or_else(|| PathBuf::from("/var/lib/zlayer"))
728}
729
730/// Resolve the Windows system-wide ZLayer data root.
731///
732/// Uses `%ProgramData%` (typically `C:\ProgramData`) when present, falling
733/// back to the literal `C:\ProgramData\ZLayer` path when the env var is
734/// missing (as can happen under a stripped-down service account).
735#[cfg(target_os = "windows")]
736fn windows_program_data_root() -> PathBuf {
737    if let Some(program_data) = std::env::var_os("PROGRAMDATA") {
738        let mut p = PathBuf::from(program_data);
739        p.push("ZLayer");
740        p
741    } else {
742        PathBuf::from(r"C:\ProgramData\ZLayer")
743    }
744}
745
746/// Returns `true` when the current process is running with superuser /
747/// Administrator privileges.
748///
749/// - Unix: true when the effective UID is `0`.
750/// - Windows: true when the current process token is a member of the
751///   built-in Administrators group (checked via `IsUserAnAdmin`).
752/// - Other targets: always returns `false`.
753#[cfg(unix)]
754#[must_use]
755pub fn is_root() -> bool {
756    // SAFETY: `geteuid` is always safe to call and is thread-safe.
757    unsafe { libc::geteuid() == 0 }
758}
759
760/// Windows analogue of the Unix `is_root()` check.
761///
762/// Returns `true` when the current process token carries administrator
763/// privileges (elevated or running as LocalSystem). Backed by
764/// `IsUserAnAdmin` from `shell32`, which is the cheapest call that
765/// covers both interactive elevation and service-account scenarios.
766///
767/// The name `is_root()` is retained deliberately: callers across the
768/// workspace gate privileged filesystem/network setup on this
769/// function, and "admin" is the closest semantic analogue on Windows.
770///
771/// Exposed as `pub` so the Windows cluster-bootstrap CLI
772/// (`zlayer node init` / `zlayer node join` / `zlayer join`) can gate
773/// privileged subsystem setup (service-manager registration, firewall
774/// rules, Wintun adapter creation) on elevation before attempting them.
775#[cfg(windows)]
776#[must_use]
777pub fn is_root() -> bool {
778    use windows::Win32::UI::Shell::IsUserAnAdmin;
779    // SAFETY: `IsUserAnAdmin` has no preconditions and returns a BOOL.
780    unsafe { IsUserAnAdmin().as_bool() }
781}
782
783/// Fallback for non-unix, non-windows targets.
784#[cfg(not(any(unix, windows)))]
785#[must_use]
786pub fn is_root() -> bool {
787    false
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    // Tests below mutate environment variables (`PROGRAMDATA` on Windows,
795    // `ZLAYER_DATA_DIR` on Linux/macOS) to exercise platform-default and
796    // env-override path resolution. Cargo runs tests concurrently, so
797    // readers (`system_default`, `default_admin_bearer_path`, the
798    // `default_*_for` helpers) must serialize against the mutators or
799    // they race and observe a mix of pre- and post-mutation env state.
800    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
801
802    #[test]
803    fn layer_store_shares_data_dir_filesystem_with_bundles() {
804        // The overlay layer store and the bundle dir (which holds the
805        // upper/work dirs) MUST share a filesystem for the overlay mount and
806        // the rename-based atomic publish to work — both are direct children
807        // of data_dir, so they always do.
808        let dirs = ZLayerDirs::new("/test/data");
809        assert_eq!(dirs.layer_store().parent(), dirs.bundles().parent());
810        assert_eq!(dirs.layer_store().parent(), Some(dirs.data_dir()));
811    }
812
813    #[test]
814    fn sanitize_digest_matches_blob_staging_filename_rule() {
815        // Must stay byte-identical to zlayer-registry's blob_staging_filename:
816        // `digest.replace([':', '/'], "_")`.
817        assert_eq!(
818            super::sanitize_digest("sha256:abc123"),
819            "sha256_abc123",
820            "colon is replaced with underscore"
821        );
822        assert_eq!(
823            super::sanitize_digest("sha256:ab/cd"),
824            "sha256_ab_cd",
825            "both colon and slash are replaced"
826        );
827        // No special chars -> identity.
828        assert_eq!(super::sanitize_digest("plainname"), "plainname");
829        // The result is a single path component (no separators survive).
830        let s = super::sanitize_digest("sha256:deadbeef");
831        assert_eq!(std::path::Path::new(&s).components().count(), 1);
832    }
833
834    #[test]
835    fn subdirectories_are_relative_to_data_dir() {
836        let dirs = ZLayerDirs::new("/test/data");
837        assert_eq!(dirs.containers(), PathBuf::from("/test/data/containers"));
838        assert_eq!(dirs.rootfs(), PathBuf::from("/test/data/rootfs"));
839        assert_eq!(dirs.bundles(), PathBuf::from("/test/data/bundles"));
840        assert_eq!(dirs.layer_store(), PathBuf::from("/test/data/layers"));
841        assert_eq!(dirs.cache(), PathBuf::from("/test/data/cache"));
842        assert_eq!(dirs.volumes(), PathBuf::from("/test/data/volumes"));
843        assert_eq!(dirs.wasm(), PathBuf::from("/test/data/wasm"));
844        assert_eq!(
845            dirs.wasm_compiled(),
846            PathBuf::from("/test/data/wasm/compiled")
847        );
848        assert_eq!(dirs.secrets(), PathBuf::from("/test/data/secrets"));
849        assert_eq!(dirs.certs(), PathBuf::from("/test/data/certs"));
850        assert_eq!(dirs.raft(), PathBuf::from("/test/data/raft"));
851        assert_eq!(
852            dirs.admin_password(),
853            PathBuf::from("/test/data/admin_password")
854        );
855        assert_eq!(dirs.daemon_json(), PathBuf::from("/test/data/daemon.json"));
856        assert_eq!(dirs.logs(), PathBuf::from("/test/data/logs"));
857        assert_eq!(dirs.vms(), PathBuf::from("/test/data/vms"));
858        assert_eq!(dirs.images(), PathBuf::from("/test/data/images"));
859        assert_eq!(dirs.bin(), PathBuf::from("/test/data/bin"));
860        assert_eq!(
861            dirs.buildd_bin(),
862            PathBuf::from("/test/data/bin/zlayer-buildd")
863        );
864        assert_eq!(dirs.buildd(), PathBuf::from("/test/data/buildd"));
865        assert_eq!(
866            dirs.toolchain_cache(),
867            PathBuf::from("/test/data/toolchain-cache")
868        );
869        assert_eq!(dirs.tmp(), PathBuf::from("/test/data/tmp"));
870    }
871
872    #[test]
873    fn system_default_uses_default_data_dir() {
874        let _env_guard = ENV_LOCK.lock().unwrap();
875        let dirs = ZLayerDirs::system_default();
876        assert_eq!(dirs.data_dir(), ZLayerDirs::default_data_dir().as_path());
877    }
878
879    #[test]
880    fn admin_bearer_path_is_under_data_dir() {
881        let dirs = ZLayerDirs::new(PathBuf::from("/var/lib/zlayer-test"));
882        assert_eq!(
883            dirs.admin_bearer_path(),
884            PathBuf::from("/var/lib/zlayer-test/admin_bearer.token")
885        );
886    }
887
888    #[test]
889    fn default_admin_bearer_path_matches_system_default() {
890        let _env_guard = ENV_LOCK.lock().unwrap();
891        assert_eq!(
892            default_admin_bearer_path(),
893            ZLayerDirs::system_default().admin_bearer_path()
894        );
895    }
896
897    #[cfg(target_os = "windows")]
898    #[test]
899    fn windows_default_data_dir_uses_program_data() {
900        let _env_guard = ENV_LOCK.lock().unwrap();
901        let prev = std::env::var_os("PROGRAMDATA");
902        std::env::set_var("PROGRAMDATA", r"C:\TestProgramData");
903
904        let data = ZLayerDirs::default_data_dir();
905        assert_eq!(data, PathBuf::from(r"C:\TestProgramData\ZLayer"));
906
907        // Sub-paths should live under the ProgramData root.
908        let dirs = ZLayerDirs::system_default();
909        assert_eq!(dirs.certs(), data.join("certs"));
910        assert_eq!(dirs.secrets(), data.join("secrets"));
911        assert_eq!(dirs.logs(), data.join("logs"));
912
913        // Run/log helpers should also honour the ProgramData root.
914        assert_eq!(ZLayerDirs::default_run_dir(), data.join("run"));
915        assert_eq!(ZLayerDirs::default_log_dir(), data.join("logs"));
916
917        // Socket path on Windows is a TCP loopback endpoint, not a filesystem
918        // path.
919        assert_eq!(ZLayerDirs::default_socket_path(), "tcp://127.0.0.1:3669");
920
921        match prev {
922            Some(v) => std::env::set_var("PROGRAMDATA", v),
923            None => std::env::remove_var("PROGRAMDATA"),
924        }
925    }
926
927    #[test]
928    fn default_log_dir_for_returns_system_path_when_data_dir_is_default() {
929        let _env_guard = ENV_LOCK.lock().unwrap();
930        let system_default = ZLayerDirs::default_data_dir();
931        let result = ZLayerDirs::default_log_dir_for(&system_default);
932        assert_eq!(result, ZLayerDirs::default_log_dir());
933    }
934
935    #[test]
936    fn default_log_dir_for_returns_data_subdir_when_data_dir_overridden() {
937        let _env_guard = ENV_LOCK.lock().unwrap();
938        let tmp = tempfile::tempdir().expect("create tempdir");
939        let custom = tmp.path().to_path_buf();
940        let result = ZLayerDirs::default_log_dir_for(&custom);
941        assert_eq!(result, custom.join("logs"));
942        // Sanity: must NOT be the system default path on Linux/macOS/Windows.
943        assert_ne!(result, ZLayerDirs::default_log_dir());
944    }
945
946    #[test]
947    fn default_run_dir_for_returns_system_path_when_data_dir_is_default() {
948        let _env_guard = ENV_LOCK.lock().unwrap();
949        let system_default = ZLayerDirs::default_data_dir();
950        let result = ZLayerDirs::default_run_dir_for(&system_default);
951        assert_eq!(result, ZLayerDirs::default_run_dir());
952    }
953
954    #[test]
955    fn default_run_dir_for_returns_data_subdir_when_data_dir_overridden() {
956        let _env_guard = ENV_LOCK.lock().unwrap();
957        let tmp = tempfile::tempdir().expect("create tempdir");
958        let custom = tmp.path().to_path_buf();
959        let result = ZLayerDirs::default_run_dir_for(&custom);
960        assert_eq!(result, custom.join("run"));
961        assert_ne!(result, ZLayerDirs::default_run_dir());
962    }
963
964    #[test]
965    fn default_socket_path_for_returns_system_path_when_data_dir_is_default() {
966        let _env_guard = ENV_LOCK.lock().unwrap();
967        let system_default = ZLayerDirs::default_data_dir();
968        let result = ZLayerDirs::default_socket_path_for(&system_default);
969        assert_eq!(result, ZLayerDirs::default_socket_path());
970    }
971
972    #[cfg(not(target_os = "windows"))]
973    #[test]
974    fn default_socket_path_for_returns_data_subdir_when_data_dir_overridden() {
975        let _env_guard = ENV_LOCK.lock().unwrap();
976        let tmp = tempfile::tempdir().expect("create tempdir");
977        let custom = tmp.path().to_path_buf();
978        let result = ZLayerDirs::default_socket_path_for(&custom);
979        let expected = custom
980            .join("run")
981            .join("zlayer.sock")
982            .to_string_lossy()
983            .into_owned();
984        assert_eq!(result, expected);
985        assert_ne!(result, ZLayerDirs::default_socket_path());
986    }
987
988    #[cfg(target_os = "windows")]
989    #[test]
990    fn default_socket_path_for_always_tcp_on_windows() {
991        let _env_guard = ENV_LOCK.lock().unwrap();
992        let tmp = tempfile::tempdir().expect("create tempdir");
993        let custom = tmp.path().to_path_buf();
994        // On Windows the daemon listens on TCP loopback regardless of data_dir.
995        assert_eq!(
996            ZLayerDirs::default_socket_path_for(&custom),
997            "tcp://127.0.0.1:3669"
998        );
999    }
1000
1001    #[cfg(not(target_os = "windows"))]
1002    #[test]
1003    fn default_socket_path_uses_data_subdir_when_env_var_overrides() {
1004        let _env_guard = ENV_LOCK.lock().unwrap();
1005        let prev = std::env::var_os("ZLAYER_DATA_DIR");
1006        let tmp = tempfile::tempdir().expect("create tempdir");
1007        let custom = tmp.path().to_path_buf();
1008        std::env::set_var("ZLAYER_DATA_DIR", &custom);
1009
1010        let result = ZLayerDirs::default_socket_path();
1011        let expected = custom
1012            .join("run")
1013            .join("zlayer.sock")
1014            .to_string_lossy()
1015            .into_owned();
1016        assert_eq!(result, expected);
1017
1018        match prev {
1019            Some(v) => std::env::set_var("ZLAYER_DATA_DIR", v),
1020            None => std::env::remove_var("ZLAYER_DATA_DIR"),
1021        }
1022    }
1023
1024    #[cfg(target_os = "windows")]
1025    #[test]
1026    fn default_socket_path_uses_data_subdir_when_env_var_overrides() {
1027        let _env_guard = ENV_LOCK.lock().unwrap();
1028        // On Windows the socket is always TCP loopback regardless of
1029        // `ZLAYER_DATA_DIR`.
1030        let prev = std::env::var_os("ZLAYER_DATA_DIR");
1031        let tmp = tempfile::tempdir().expect("create tempdir");
1032        let custom = tmp.path().to_path_buf();
1033        std::env::set_var("ZLAYER_DATA_DIR", &custom);
1034
1035        assert_eq!(ZLayerDirs::default_socket_path(), "tcp://127.0.0.1:3669");
1036
1037        match prev {
1038            Some(v) => std::env::set_var("ZLAYER_DATA_DIR", v),
1039            None => std::env::remove_var("ZLAYER_DATA_DIR"),
1040        }
1041    }
1042
1043    #[cfg(target_os = "windows")]
1044    #[test]
1045    fn windows_default_data_dir_fallback_when_env_missing() {
1046        let _env_guard = ENV_LOCK.lock().unwrap();
1047        let prev = std::env::var_os("PROGRAMDATA");
1048        std::env::remove_var("PROGRAMDATA");
1049
1050        let data = ZLayerDirs::default_data_dir();
1051        assert_eq!(data, PathBuf::from(r"C:\ProgramData\ZLayer"));
1052
1053        if let Some(v) = prev {
1054            std::env::set_var("PROGRAMDATA", v);
1055        }
1056    }
1057
1058    #[test]
1059    fn default_docker_socket_path_not_empty() {
1060        let result = ZLayerDirs::default_docker_socket_path();
1061        assert!(!result.is_empty());
1062    }
1063
1064    #[cfg(target_os = "windows")]
1065    #[test]
1066    fn default_docker_socket_path_platform_shape() {
1067        let result = ZLayerDirs::default_docker_socket_path();
1068        assert!(result.starts_with(r"\\.\pipe"));
1069    }
1070
1071    #[cfg(target_os = "macos")]
1072    #[test]
1073    fn default_docker_socket_path_platform_shape() {
1074        let result = ZLayerDirs::default_docker_socket_path();
1075        assert!(result.ends_with("/docker.sock"));
1076    }
1077
1078    #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
1079    #[test]
1080    fn default_docker_socket_path_platform_shape() {
1081        let result = ZLayerDirs::default_docker_socket_path();
1082        assert!(result.ends_with("/docker.sock"));
1083    }
1084
1085    #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
1086    #[test]
1087    fn wireguard_returns_fhs_path_on_default_data_dir() {
1088        let dirs = ZLayerDirs::system_default();
1089        assert_eq!(dirs.wireguard(), PathBuf::from("/var/run/wireguard"));
1090    }
1091
1092    #[test]
1093    fn wireguard_returns_data_subdir_when_overridden() {
1094        let tmp = tempfile::tempdir().expect("create tempdir");
1095        let custom = tmp.path().to_path_buf();
1096        let dirs = ZLayerDirs::new(&custom);
1097        let result = dirs.wireguard();
1098        assert_eq!(result, custom.join("run").join("wireguard"));
1099        // Sanity: must NOT be the FHS path on Linux when the data dir is custom.
1100        #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
1101        assert_ne!(result, PathBuf::from("/var/run/wireguard"));
1102    }
1103
1104    #[cfg(target_os = "macos")]
1105    #[test]
1106    fn wireguard_always_returns_data_subdir_on_macos() {
1107        // On macOS there is no FHS convention; even the system-default data
1108        // dir gets `{data_dir}/run/wireguard`.
1109        let dirs = ZLayerDirs::system_default();
1110        let expected = ZLayerDirs::default_data_dir().join("run").join("wireguard");
1111        assert_eq!(dirs.wireguard(), expected);
1112    }
1113
1114    #[test]
1115    fn scratch_dir_under_data_tmp() {
1116        let parent = tempfile::tempdir().expect("parent");
1117        let dirs = ZLayerDirs::new(parent.path());
1118        let s = dirs.scratch_dir("zlayer-test-").expect("scratch_dir");
1119        assert!(s.path().starts_with(dirs.tmp()));
1120        assert!(s.path().is_dir());
1121        let kept = s.path().to_path_buf();
1122        drop(s);
1123        assert!(!kept.exists());
1124    }
1125
1126    #[test]
1127    fn scratch_file_under_data_tmp() {
1128        let parent = tempfile::tempdir().expect("parent");
1129        let dirs = ZLayerDirs::new(parent.path());
1130        let f = dirs.scratch_file("zlayer-test-").expect("scratch_file");
1131        assert!(f.path().starts_with(dirs.tmp()));
1132        assert!(f.path().is_file());
1133        let kept = f.path().to_path_buf();
1134        drop(f);
1135        assert!(!kept.exists());
1136    }
1137
1138    #[cfg(not(target_os = "windows"))]
1139    #[test]
1140    fn default_socket_path_for_falls_back_when_path_too_long() {
1141        let deep = PathBuf::from(
1142            "/var/lib/forgejo-runner/workdir/9dbc274201705d7d/hostexecutor/target/zlayer-e2e/cluster_3node/node1/data",
1143        );
1144        let result = ZLayerDirs::default_socket_path_for(&deep);
1145        assert!(
1146            result.len() <= SUN_PATH_MAX,
1147            "fallback path overflows sun_path: len={} path={}",
1148            result.len(),
1149            result,
1150        );
1151        // Determinism.
1152        assert_eq!(result, ZLayerDirs::default_socket_path_for(&deep));
1153        // Distinct from another data_dir.
1154        let other = deep.parent().unwrap().to_path_buf();
1155        assert_ne!(result, ZLayerDirs::default_socket_path_for(&other));
1156    }
1157
1158    #[cfg(not(target_os = "windows"))]
1159    #[test]
1160    fn default_socket_path_for_keeps_natural_path_when_short() {
1161        let tmp = tempfile::tempdir().expect("create tempdir");
1162        let short = tmp.path().to_path_buf();
1163        assert!(short.to_string_lossy().len() < 80);
1164        let result = ZLayerDirs::default_socket_path_for(&short);
1165        assert!(result.ends_with("/run/zlayer.sock"));
1166        assert!(result.starts_with(&*short.to_string_lossy()));
1167    }
1168
1169    /// Regression: a NON-root caller pointed at the system data dir
1170    /// `/var/lib/zlayer` (as `detect_data_dir` returns when a system install is
1171    /// present) must resolve the FHS `/var/run/zlayer.sock` the root daemon
1172    /// actually listens on — NOT a derived `/var/lib/zlayer/run/zlayer.sock`.
1173    /// Before the `fhs_system_data_dir` fix this returned the derived path for
1174    /// non-root callers because the FHS decision keyed off the euid-dependent
1175    /// `platform_default_data_dir()`. This test runs as non-root in CI, which is
1176    /// exactly the broken case.
1177    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1178    #[test]
1179    fn system_data_dir_resolves_fhs_paths_regardless_of_euid() {
1180        let system = PathBuf::from("/var/lib/zlayer");
1181        assert_eq!(
1182            ZLayerDirs::default_socket_path_for(&system),
1183            "/var/run/zlayer.sock",
1184            "non-root caller pointed at the system data dir must get the FHS socket"
1185        );
1186        assert_eq!(
1187            ZLayerDirs::default_run_dir_for(&system),
1188            PathBuf::from("/var/run/zlayer")
1189        );
1190        assert_eq!(
1191            ZLayerDirs::default_log_dir_for(&system),
1192            PathBuf::from("/var/log/zlayer")
1193        );
1194    }
1195
1196    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1197    #[test]
1198    fn wireguard_dir_falls_back_when_path_too_long() {
1199        let deep = PathBuf::from(
1200            "/var/lib/forgejo-runner/workdir/9dbc274201705d7d/hostexecutor/target/zlayer-e2e/cluster_3node/node1/data",
1201        );
1202        let dirs = ZLayerDirs::new(&deep);
1203        let wg = dirs.wireguard();
1204        // 21 = "/" + IFNAMSIZ(15) + ".sock"(5)
1205        assert!(
1206            wg.to_string_lossy().len() + 21 <= SUN_PATH_MAX,
1207            "wireguard dir + ifname overflows: dir={}",
1208            wg.display(),
1209        );
1210    }
1211}