Skip to main content

use_oci_annotation/
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 key for a title.
8pub const OCI_TITLE: &str = "org.opencontainers.image.title";
9/// Common OCI annotation key for a description.
10pub const OCI_DESCRIPTION: &str = "org.opencontainers.image.description";
11/// Common OCI annotation key for a source URL.
12pub const OCI_SOURCE: &str = "org.opencontainers.image.source";
13/// Common OCI annotation key for a revision.
14pub const OCI_REVISION: &str = "org.opencontainers.image.revision";
15/// Common OCI annotation key for a version.
16pub const OCI_VERSION: &str = "org.opencontainers.image.version";
17/// Common OCI annotation key for creation metadata.
18pub const OCI_CREATED: &str = "org.opencontainers.image.created";
19/// Common OCI annotation key for authors.
20pub const OCI_AUTHORS: &str = "org.opencontainers.image.authors";
21/// Common OCI annotation key for licenses.
22pub const OCI_LICENSES: &str = "org.opencontainers.image.licenses";
23/// Common OCI annotation key for documentation.
24pub const OCI_DOCUMENTATION: &str = "org.opencontainers.image.documentation";
25/// Common OCI annotation key for a URL.
26pub const OCI_URL: &str = "org.opencontainers.image.url";
27
28/// Errors returned when annotation text is invalid.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum AnnotationError {
31    EmptyKey,
32    InvalidKey,
33    InvalidValue,
34}
35
36impl fmt::Display for AnnotationError {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::EmptyKey => formatter.write_str("OCI annotation key cannot be empty"),
40            Self::InvalidKey => formatter.write_str("invalid OCI annotation key"),
41            Self::InvalidValue => formatter.write_str("invalid OCI annotation value"),
42        }
43    }
44}
45
46impl Error for AnnotationError {}
47
48/// A validated OCI annotation key.
49#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct AnnotationKey(String);
51
52impl AnnotationKey {
53    /// Creates an annotation key.
54    pub fn new(value: impl AsRef<str>) -> Result<Self, AnnotationError> {
55        let trimmed = value.as_ref().trim();
56        validate_key(trimmed)?;
57        Ok(Self(trimmed.to_string()))
58    }
59
60    /// Returns the key text.
61    #[must_use]
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67impl AsRef<str> for AnnotationKey {
68    fn as_ref(&self) -> &str {
69        self.as_str()
70    }
71}
72
73impl fmt::Display for AnnotationKey {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter.write_str(self.as_str())
76    }
77}
78
79impl FromStr for AnnotationKey {
80    type Err = AnnotationError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        Self::new(value)
84    }
85}
86
87impl TryFrom<&str> for AnnotationKey {
88    type Error = AnnotationError;
89
90    fn try_from(value: &str) -> Result<Self, Self::Error> {
91        Self::new(value)
92    }
93}
94
95/// A validated OCI annotation value.
96#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub struct AnnotationValue(String);
98
99impl AnnotationValue {
100    /// Creates an annotation value.
101    pub fn new(value: impl AsRef<str>) -> Result<Self, AnnotationError> {
102        let value = value.as_ref();
103        if value.contains('\0') {
104            return Err(AnnotationError::InvalidValue);
105        }
106        Ok(Self(value.to_string()))
107    }
108
109    /// Returns the value text.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114}
115
116impl AsRef<str> for AnnotationValue {
117    fn as_ref(&self) -> &str {
118        self.as_str()
119    }
120}
121
122impl fmt::Display for AnnotationValue {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str(self.as_str())
125    }
126}
127
128/// An OCI annotation key/value pair.
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
130pub struct Annotation {
131    key: AnnotationKey,
132    value: AnnotationValue,
133}
134
135impl Annotation {
136    /// Creates an annotation.
137    pub fn new(key: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, AnnotationError> {
138        Ok(Self {
139            key: AnnotationKey::new(key)?,
140            value: AnnotationValue::new(value)?,
141        })
142    }
143
144    /// Creates an OCI title annotation.
145    pub fn title(value: impl AsRef<str>) -> Result<Self, AnnotationError> {
146        Self::new(OCI_TITLE, value)
147    }
148
149    /// Creates an OCI description annotation.
150    pub fn description(value: impl AsRef<str>) -> Result<Self, AnnotationError> {
151        Self::new(OCI_DESCRIPTION, value)
152    }
153
154    /// Creates an OCI source annotation.
155    pub fn source(value: impl AsRef<str>) -> Result<Self, AnnotationError> {
156        Self::new(OCI_SOURCE, value)
157    }
158
159    /// Returns the key.
160    #[must_use]
161    pub const fn key(&self) -> &AnnotationKey {
162        &self.key
163    }
164
165    /// Returns the value.
166    #[must_use]
167    pub const fn value(&self) -> &AnnotationValue {
168        &self.value
169    }
170}
171
172impl fmt::Display for Annotation {
173    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(formatter, "{}={}", self.key, self.value)
175    }
176}
177
178fn validate_key(value: &str) -> Result<(), AnnotationError> {
179    if value.is_empty() {
180        return Err(AnnotationError::EmptyKey);
181    }
182    if value.starts_with(['.', '/', '-'])
183        || value.ends_with(['.', '/', '-'])
184        || value.chars().any(char::is_whitespace)
185        || !value
186            .bytes()
187            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'/'))
188    {
189        Err(AnnotationError::InvalidKey)
190    } else {
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{Annotation, AnnotationError, AnnotationKey, OCI_TITLE};
198
199    #[test]
200    fn validates_and_renders_annotations() -> Result<(), Box<dyn std::error::Error>> {
201        let annotation = Annotation::title("RustUse OCI")?;
202
203        assert_eq!(annotation.key().as_str(), OCI_TITLE);
204        assert_eq!(
205            annotation.to_string(),
206            "org.opencontainers.image.title=RustUse OCI"
207        );
208        assert_eq!(
209            AnnotationKey::new("bad key"),
210            Err(AnnotationError::InvalidKey)
211        );
212        assert_eq!(
213            Annotation::new("example.key", "bad\0value"),
214            Err(AnnotationError::InvalidValue)
215        );
216        Ok(())
217    }
218}