Skip to main content

use_docker_label/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Common `OCI` annotation label for an image title.
8pub const OCI_TITLE: &str = "org.opencontainers.image.title";
9/// Common `OCI` annotation label for a description.
10pub const OCI_DESCRIPTION: &str = "org.opencontainers.image.description";
11/// Common `OCI` annotation label for a version.
12pub const OCI_VERSION: &str = "org.opencontainers.image.version";
13/// Common `OCI` annotation label for a source URL.
14pub const OCI_SOURCE: &str = "org.opencontainers.image.source";
15/// Common `OCI` annotation label for a revision.
16pub const OCI_REVISION: &str = "org.opencontainers.image.revision";
17/// Common `OCI` annotation label for license metadata.
18pub const OCI_LICENSES: &str = "org.opencontainers.image.licenses";
19
20/// Error returned when Docker label text is invalid.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum DockerLabelError {
23    /// The label key was empty.
24    EmptyKey,
25    /// The label key contained unsupported syntax.
26    InvalidKey,
27    /// The label value contained a NUL byte.
28    InvalidValue,
29}
30
31impl fmt::Display for DockerLabelError {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::EmptyKey => formatter.write_str("Docker label key cannot be empty"),
35            Self::InvalidKey => formatter.write_str("invalid Docker label key"),
36            Self::InvalidValue => formatter.write_str("invalid Docker label value"),
37        }
38    }
39}
40
41impl Error for DockerLabelError {}
42
43/// A validated Docker label key.
44#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub struct DockerLabelKey(String);
46
47impl DockerLabelKey {
48    /// Creates a label key.
49    pub fn new(value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
50        let trimmed = value.as_ref().trim();
51        validate_key(trimmed)?;
52        Ok(Self(trimmed.to_string()))
53    }
54
55    /// Returns the key text.
56    #[must_use]
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60}
61
62impl AsRef<str> for DockerLabelKey {
63    fn as_ref(&self) -> &str {
64        self.as_str()
65    }
66}
67
68impl fmt::Display for DockerLabelKey {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str(self.as_str())
71    }
72}
73
74impl FromStr for DockerLabelKey {
75    type Err = DockerLabelError;
76
77    fn from_str(value: &str) -> Result<Self, Self::Err> {
78        Self::new(value)
79    }
80}
81
82/// A Docker label key/value pair.
83#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub struct DockerLabel {
85    key: DockerLabelKey,
86    value: String,
87}
88
89impl DockerLabel {
90    /// Creates a Docker label.
91    pub fn new(key: DockerLabelKey, value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
92        let value = value.as_ref();
93        if value.contains('\0') {
94            return Err(DockerLabelError::InvalidValue);
95        }
96        Ok(Self {
97            key,
98            value: value.to_string(),
99        })
100    }
101
102    /// Creates a common `OCI` title label.
103    pub fn oci_title(value: impl AsRef<str>) -> Result<Self, DockerLabelError> {
104        Self::new(DockerLabelKey::new(OCI_TITLE)?, value)
105    }
106
107    /// Returns the label key.
108    #[must_use]
109    pub const fn key(&self) -> &DockerLabelKey {
110        &self.key
111    }
112
113    /// Returns the label value.
114    #[must_use]
115    pub fn value(&self) -> &str {
116        &self.value
117    }
118}
119
120impl fmt::Display for DockerLabel {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(formatter, "{}={}", self.key, self.value)
123    }
124}
125
126fn validate_key(value: &str) -> Result<(), DockerLabelError> {
127    if value.is_empty() {
128        return Err(DockerLabelError::EmptyKey);
129    }
130    if value.starts_with(['.', '/', '-'])
131        || value.ends_with(['.', '/', '-'])
132        || value.chars().any(char::is_whitespace)
133        || !value
134            .bytes()
135            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'/'))
136    {
137        Err(DockerLabelError::InvalidKey)
138    } else {
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::{DockerLabel, DockerLabelError, DockerLabelKey, OCI_TITLE};
146
147    #[test]
148    fn validates_and_renders_labels() -> Result<(), Box<dyn std::error::Error>> {
149        let label = DockerLabel::new(DockerLabelKey::new(OCI_TITLE)?, "RustUse app")?;
150
151        assert_eq!(
152            label.to_string(),
153            "org.opencontainers.image.title=RustUse app"
154        );
155        assert_eq!(
156            DockerLabelKey::new("bad key"),
157            Err(DockerLabelError::InvalidKey)
158        );
159        assert_eq!(DockerLabel::oci_title("RustUse")?.key().as_str(), OCI_TITLE);
160        Ok(())
161    }
162}