Skip to main content

kcode_rust_libs_v2/
model.rs

1use std::error::Error as StdError;
2use std::fmt;
3use std::io;
4use std::path::PathBuf;
5
6/// The result type returned by this crate.
7pub type Result<T> = std::result::Result<T, Error>;
8
9/// A canonical UTF-8 path relative to a managed Rust-library root.
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct RustLibPath(String);
12
13impl RustLibPath {
14    /// Validates and constructs a managed-library path.
15    pub fn new(path: impl Into<String>) -> Result<Self> {
16        let path = path.into();
17        validate_path(&path)?;
18        Ok(Self(path))
19    }
20
21    /// Returns the canonical `/`-separated representation.
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25}
26
27impl fmt::Display for RustLibPath {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.write_str(&self.0)
30    }
31}
32
33fn validate_path(path: &str) -> Result<()> {
34    let invalid = |reason| Error::InvalidRustLibPath {
35        path: path.to_owned(),
36        reason,
37    };
38    if path.is_empty() {
39        return Err(invalid("path is empty"));
40    }
41    if path.starts_with('/') || path.ends_with('/') {
42        return Err(invalid("path must not begin or end with '/'"));
43    }
44    if path.contains('\\') {
45        return Err(invalid("backslashes are not allowed"));
46    }
47    if path.contains(':') {
48        return Err(invalid("colons are not allowed"));
49    }
50    if path.contains('\0') {
51        return Err(invalid("NUL is not allowed"));
52    }
53    if path
54        .split('/')
55        .any(|component| component.is_empty() || component == "." || component == "..")
56    {
57        return Err(invalid("empty, '.' and '..' components are not allowed"));
58    }
59    Ok(())
60}
61
62/// One complete UTF-8 file in a managed Rust library.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct RustLibFile {
65    pub path: RustLibPath,
66    pub contents: String,
67}
68
69impl RustLibFile {
70    pub fn new(path: RustLibPath, contents: impl Into<String>) -> Self {
71        Self {
72            path,
73            contents: contents.into(),
74        }
75    }
76}
77
78/// One fixed stage in the managed validation pipeline.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum CheckStage {
81    Fetch,
82    Format,
83    Build,
84    Clippy,
85    Test,
86    DocTest,
87}
88
89impl CheckStage {
90    pub fn name(self) -> &'static str {
91        match self {
92            Self::Fetch => "fetch",
93            Self::Format => "format",
94            Self::Build => "build",
95            Self::Clippy => "clippy",
96            Self::Test => "test",
97            Self::DocTest => "doc-test",
98        }
99    }
100}
101
102/// Captured output from one completed validation stage.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct CheckStageResult {
105    pub stage: CheckStage,
106    pub command: String,
107    pub exit_code: Option<i32>,
108    pub stdout: String,
109    pub stderr: String,
110}
111
112impl CheckStageResult {
113    pub fn passed(&self) -> bool {
114        self.exit_code == Some(0)
115    }
116}
117
118/// Ordered results from a managed validation run.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct CheckResult {
121    pub stages: Vec<CheckStageResult>,
122}
123
124impl CheckResult {
125    pub fn passed(&self) -> bool {
126        self.stages.len() == 6 && self.stages.iter().all(CheckStageResult::passed)
127    }
128
129    pub fn failure(&self) -> Option<&CheckStageResult> {
130        self.stages.iter().find(|stage| !stage.passed())
131    }
132}
133
134/// An error that prevented a managed-library operation.
135#[derive(Debug)]
136pub enum Error {
137    InvalidRustLibName(String),
138    InvalidRustLibPath {
139        path: String,
140        reason: &'static str,
141    },
142    RustLibAlreadyExists(String),
143    RustLibNotFound(String),
144    RustLibIsNotDirectory(String),
145    DuplicateWritePath(String),
146    NonUtf8Path(PathBuf),
147    NonUtf8File(PathBuf),
148    SymlinkNotAllowed(PathBuf),
149    UnsupportedFileType(PathBuf),
150    RootsOverlap {
151        rust_libs_root: PathBuf,
152        work_root: PathBuf,
153    },
154    MissingRequiredFile(&'static str),
155    InvalidVersion(String),
156    InvalidCargoManifest(String),
157    InvalidRegistryToken,
158    CheckFailed(CheckResult),
159    Io {
160        action: &'static str,
161        path: PathBuf,
162        source: io::Error,
163    },
164    Sandbox {
165        stage: String,
166        message: String,
167    },
168    Publish(String),
169}
170
171impl Error {
172    pub(crate) fn io(action: &'static str, path: impl Into<PathBuf>, source: io::Error) -> Self {
173        Self::Io {
174            action,
175            path: path.into(),
176            source,
177        }
178    }
179}
180
181impl fmt::Display for Error {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::InvalidRustLibName(name) => write!(
185                f,
186                "invalid Rust library name {name:?}; use ASCII letters, digits, '-' or '_'"
187            ),
188            Self::InvalidRustLibPath { path, reason } => {
189                write!(f, "invalid Rust library path {path:?}: {reason}")
190            }
191            Self::RustLibAlreadyExists(name) => {
192                write!(f, "Rust library {name:?} already exists")
193            }
194            Self::RustLibNotFound(name) => write!(f, "Rust library {name:?} was not found"),
195            Self::RustLibIsNotDirectory(name) => {
196                write!(f, "Rust library {name:?} is not a directory")
197            }
198            Self::DuplicateWritePath(path) => {
199                write!(f, "write batch contains duplicate path {path:?}")
200            }
201            Self::NonUtf8Path(path) => {
202                write!(
203                    f,
204                    "Rust library contains a non-UTF-8 path: {}",
205                    path.display()
206                )
207            }
208            Self::NonUtf8File(path) => {
209                write!(f, "Rust library file is not UTF-8: {}", path.display())
210            }
211            Self::SymlinkNotAllowed(path) => {
212                write!(
213                    f,
214                    "Rust library symlinks are not allowed: {}",
215                    path.display()
216                )
217            }
218            Self::UnsupportedFileType(path) => write!(
219                f,
220                "Rust library entry is not a regular file or directory: {}",
221                path.display()
222            ),
223            Self::RootsOverlap {
224                rust_libs_root,
225                work_root,
226            } => write!(
227                f,
228                "Rust libraries root ({}) and work root ({}) must not overlap",
229                rust_libs_root.display(),
230                work_root.display()
231            ),
232            Self::MissingRequiredFile(path) => {
233                write!(f, "Rust library is missing required file {path}")
234            }
235            Self::InvalidVersion(value) => write!(
236                f,
237                "invalid Cargo.toml [package].version {value:?}; expected canonical major.minor.patch"
238            ),
239            Self::InvalidCargoManifest(message) => {
240                write!(f, "invalid Cargo.toml: {message}")
241            }
242            Self::InvalidRegistryToken => f.write_str("crates.io registry token is empty"),
243            Self::CheckFailed(result) => {
244                if let Some(stage) = result.failure() {
245                    write!(f, "Rust library failed the {} stage", stage.stage.name())
246                } else {
247                    f.write_str("Rust library did not complete every check stage")
248                }
249            }
250            Self::Io {
251                action,
252                path,
253                source,
254            } => write!(f, "could not {action} {}: {source}", path.display()),
255            Self::Sandbox { stage, message } => {
256                write!(f, "sandbox failure during {stage}: {message}")
257            }
258            Self::Publish(message) => write!(f, "publication failed: {message}"),
259        }
260    }
261}
262
263impl StdError for Error {
264    fn source(&self) -> Option<&(dyn StdError + 'static)> {
265        match self {
266            Self::Io { source, .. } => Some(source),
267            _ => None,
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{CheckResult, CheckStage, CheckStageResult, RustLibPath};
275
276    #[test]
277    fn validates_canonical_relative_paths() {
278        assert_eq!(
279            RustLibPath::new("src/lib.rs").unwrap().as_str(),
280            "src/lib.rs"
281        );
282        for path in ["", "/root", "tail/", "a//b", ".", "../x", "a\\b", "C:x"] {
283            assert!(RustLibPath::new(path).is_err(), "accepted {path:?}");
284        }
285    }
286
287    #[test]
288    fn a_check_passes_only_after_all_six_stages() {
289        let stages = [
290            CheckStage::Fetch,
291            CheckStage::Format,
292            CheckStage::Build,
293            CheckStage::Clippy,
294            CheckStage::Test,
295            CheckStage::DocTest,
296        ]
297        .into_iter()
298        .map(|stage| CheckStageResult {
299            stage,
300            command: stage.name().to_owned(),
301            exit_code: Some(0),
302            stdout: String::new(),
303            stderr: String::new(),
304        })
305        .collect();
306        assert!(CheckResult { stages }.passed());
307    }
308}