Skip to main content

arcbox_helper/
error.rs

1//! Structured helper errors on the tarpc wire.
2//!
3//! **Wire stability**: bincode encodes enum variants by index. Append new
4//! variants only at the end. Renaming fields is fine with serde rename; do
5//! not reorder or insert variants in the middle of a released helper major.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11/// Error returned by privileged helper RPCs.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
13#[serde(rename_all = "snake_case")]
14pub enum HelperError {
15    /// Input failed parse/validate at the RPC boundary.
16    #[error("{0}")]
17    Validation(String),
18
19    /// Destination is a symlink pointing outside ArcBox ownership.
20    #[error("{path} is a symlink to {current} (not ArcBox-owned, not replacing)")]
21    ForeignSymlink { path: String, current: String },
22
23    /// Destination exists but is not a symlink (e.g. real docker.sock).
24    #[error("{path} exists and is not a symlink (not replacing)")]
25    NotASymlink { path: String },
26
27    /// CLI link target path is itself a symlink (escape risk).
28    #[error("CLI target '{target}' is a symlink (refusing to link through it)")]
29    CliTargetIsSymlink { target: String },
30
31    /// CLI link target path does not exist on disk.
32    #[error("CLI target does not exist: {target}")]
33    CliTargetMissing { target: String },
34
35    /// CLI link target exists but is not a regular file.
36    #[error("CLI target '{target}' is not a regular file")]
37    CliTargetNotFile { target: String },
38
39    /// Canonicalized CLI target left the ArcBox xbin tree.
40    #[error("CLI target '{target}' resolves to {resolved} which is not an ArcBox xbin path")]
41    CliTargetEscaped { target: String, resolved: String },
42
43    /// File exists without the ArcBox management marker (DNS resolver, etc.).
44    #[error("{path} exists and is not ArcBox-managed (not overwriting)")]
45    ForeignManagedFile { path: String },
46
47    /// Filesystem / OS operation failed.
48    #[error("{op} {path}: {detail}")]
49    Io {
50        op: String,
51        path: String,
52        detail: String,
53    },
54
55    /// Catch-all for subsystem errors (routes, etc.) without a tighter variant.
56    ///
57    /// Prefer a dedicated variant when a call site needs structured matching.
58    #[error("{0}")]
59    Other(String),
60
61    /// `/var/run/docker.sock` (or sandboxed equivalent) is a real file/socket,
62    /// typically held by Docker Desktop — not a replaceable symlink.
63    ///
64    /// Kept separate from [`Self::NotASymlink`] so `path` stays a pure path and
65    /// UX copy is not stuffed into structured fields.
66    #[error("{path} exists but is not a symlink (is Docker Desktop running? stop it first)")]
67    DockerSocketOccupied { path: String },
68
69    /// An exact route already exists and was left untouched.
70    #[error("route conflict for {subnet}")]
71    RouteConflict { subnet: String },
72}
73
74impl HelperError {
75    #[must_use]
76    pub fn validation(msg: impl Into<String>) -> Self {
77        Self::Validation(msg.into())
78    }
79
80    #[must_use]
81    pub fn other(msg: impl Into<String>) -> Self {
82        Self::Other(msg.into())
83    }
84
85    #[must_use]
86    pub fn io(op: &str, path: impl AsRef<Path>, err: impl std::fmt::Display) -> Self {
87        Self::Io {
88            op: op.to_owned(),
89            path: path.as_ref().display().to_string(),
90            detail: err.to_string(),
91        }
92    }
93
94    #[must_use]
95    pub fn foreign_symlink(path: impl AsRef<Path>, current: impl AsRef<Path>) -> Self {
96        Self::ForeignSymlink {
97            path: path.as_ref().display().to_string(),
98            current: current.as_ref().display().to_string(),
99        }
100    }
101
102    #[must_use]
103    pub fn not_a_symlink(path: impl AsRef<Path>) -> Self {
104        Self::NotASymlink {
105            path: path.as_ref().display().to_string(),
106        }
107    }
108
109    #[must_use]
110    pub fn foreign_managed(path: impl AsRef<Path>) -> Self {
111        Self::ForeignManagedFile {
112            path: path.as_ref().display().to_string(),
113        }
114    }
115
116    #[must_use]
117    pub fn docker_socket_occupied(path: impl AsRef<Path>) -> Self {
118        Self::DockerSocketOccupied {
119            path: path.as_ref().display().to_string(),
120        }
121    }
122}
123
124/// Stable machine-readable code for metrics / Desktop branching.
125impl HelperError {
126    #[must_use]
127    pub fn code(&self) -> &'static str {
128        match self {
129            Self::Validation(_) => "validation",
130            Self::ForeignSymlink { .. } => "foreign_symlink",
131            Self::NotASymlink { .. } => "not_a_symlink",
132            Self::CliTargetIsSymlink { .. } => "cli_target_is_symlink",
133            Self::CliTargetMissing { .. } => "cli_target_missing",
134            Self::CliTargetNotFile { .. } => "cli_target_not_file",
135            Self::CliTargetEscaped { .. } => "cli_target_escaped",
136            Self::ForeignManagedFile { .. } => "foreign_managed_file",
137            Self::Io { .. } => "io",
138            Self::Other(_) => "other",
139            Self::DockerSocketOccupied { .. } => "docker_socket_occupied",
140            Self::RouteConflict { .. } => "route_conflict",
141        }
142    }
143}