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
70impl HelperError {
71    #[must_use]
72    pub fn validation(msg: impl Into<String>) -> Self {
73        Self::Validation(msg.into())
74    }
75
76    #[must_use]
77    pub fn other(msg: impl Into<String>) -> Self {
78        Self::Other(msg.into())
79    }
80
81    #[must_use]
82    pub fn io(op: &str, path: impl AsRef<Path>, err: impl std::fmt::Display) -> Self {
83        Self::Io {
84            op: op.to_owned(),
85            path: path.as_ref().display().to_string(),
86            detail: err.to_string(),
87        }
88    }
89
90    #[must_use]
91    pub fn foreign_symlink(path: impl AsRef<Path>, current: impl AsRef<Path>) -> Self {
92        Self::ForeignSymlink {
93            path: path.as_ref().display().to_string(),
94            current: current.as_ref().display().to_string(),
95        }
96    }
97
98    #[must_use]
99    pub fn not_a_symlink(path: impl AsRef<Path>) -> Self {
100        Self::NotASymlink {
101            path: path.as_ref().display().to_string(),
102        }
103    }
104
105    #[must_use]
106    pub fn foreign_managed(path: impl AsRef<Path>) -> Self {
107        Self::ForeignManagedFile {
108            path: path.as_ref().display().to_string(),
109        }
110    }
111
112    #[must_use]
113    pub fn docker_socket_occupied(path: impl AsRef<Path>) -> Self {
114        Self::DockerSocketOccupied {
115            path: path.as_ref().display().to_string(),
116        }
117    }
118}
119
120/// Stable machine-readable code for metrics / Desktop branching.
121impl HelperError {
122    #[must_use]
123    pub fn code(&self) -> &'static str {
124        match self {
125            Self::Validation(_) => "validation",
126            Self::ForeignSymlink { .. } => "foreign_symlink",
127            Self::NotASymlink { .. } => "not_a_symlink",
128            Self::CliTargetIsSymlink { .. } => "cli_target_is_symlink",
129            Self::CliTargetMissing { .. } => "cli_target_missing",
130            Self::CliTargetNotFile { .. } => "cli_target_not_file",
131            Self::CliTargetEscaped { .. } => "cli_target_escaped",
132            Self::ForeignManagedFile { .. } => "foreign_managed_file",
133            Self::Io { .. } => "io",
134            Self::Other(_) => "other",
135            Self::DockerSocketOccupied { .. } => "docker_socket_occupied",
136        }
137    }
138}