//! Application inventory types and the parsed primitives they are built from.
use std::fmt;
use serde::{Deserialize, Serialize};
/// Reason a string was rejected as a [`Host`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidHost {
/// The value was empty or longer than 253 characters.
#[error("host must be 1..=253 characters, got {0}")]
Length(usize),
/// The value contained a character outside `[A-Za-z0-9.-]`.
#[error("host contains illegal character {0:?}; allowed: letters, digits, '.', '-'")]
Character(char),
/// The value started or ended with a separator, or had an empty label.
#[error("host has an empty or misplaced label separator")]
Label,
}
/// Reason a string was rejected as an [`ImageRef`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageRef {
/// The value was empty or longer than 512 characters.
#[error("image reference must be 1..=512 characters, got {0}")]
Length(usize),
/// The value contained a character outside the OCI reference alphabet.
#[error("image reference contains illegal character {0:?}")]
Character(char),
/// The value began with `-`, which a CLI would read as a flag.
#[error("image reference must not start with '-'")]
LeadingDash,
}
/// Reason a string was rejected as an [`ImageSource`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageSource {
/// The value was empty or too long to combine with the longest OCI tag.
#[error("image source must be 1..=383 characters, got {0}")]
Length(usize),
/// The value contained a character outside the OCI reference alphabet.
#[error("image source contains illegal character {0:?}")]
Character(char),
/// The value began with `-`, which a CLI would read as a flag.
#[error("image source must not start with '-'")]
LeadingDash,
/// The source included a tag or digest; version is declared separately.
#[error("image source must not include a tag or digest; put it in version")]
Versioned,
/// The repository path had an empty component.
#[error("image source has an empty or misplaced path separator")]
Path,
}
/// Reason a string was rejected as an [`ImageVersion`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageVersion {
/// OCI tags contain at most 128 characters.
#[error("image version must be 1..=128 characters, got {0}")]
Length(usize),
/// The first tag character was not alphanumeric or `_`.
#[error("image version must start with a letter, digit, or '_'")]
LeadingCharacter,
/// The value contained a character outside the OCI tag alphabet.
#[error("image version contains illegal character {0:?}")]
Character(char),
}
/// The hostname `once` uses to address a deployed application.
///
/// Parsing is what keeps a client-supplied value from ever becoming a `once`
/// flag: the alphabet excludes whitespace, `-` in leading position, and every
/// shell metacharacter.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Host(String);
impl Host {
/// Parses a hostname, rejecting anything a CLI could misread as a flag.
pub fn parse(raw: &str) -> Result<Self, InvalidHost> {
if raw.is_empty() || raw.len() > 253 {
return Err(InvalidHost::Length(raw.len()));
}
if let Some(bad) = raw
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '.' || *c == '-'))
{
return Err(InvalidHost::Character(bad));
}
let misplaced = raw.starts_with(['-', '.'])
|| raw.ends_with(['-', '.'])
|| raw.split('.').any(str::is_empty);
if misplaced {
return Err(InvalidHost::Label);
}
Ok(Self(raw.to_owned()))
}
/// Borrows the validated hostname.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Host {
type Error = InvalidHost;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<Host> for String {
fn from(value: Host) -> Self {
value.0
}
}
impl fmt::Display for Host {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// An OCI image reference such as `ghcr.io/basecamp/writebook:1.4.0`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageRef(String);
impl ImageRef {
/// Parses an image reference, rejecting flag-shaped and shell-unsafe input.
pub fn parse(raw: &str) -> Result<Self, InvalidImageRef> {
if raw.is_empty() || raw.len() > 512 {
return Err(InvalidImageRef::Length(raw.len()));
}
if raw.starts_with('-') {
return Err(InvalidImageRef::LeadingDash);
}
if let Some(bad) = raw.chars().find(|c| !is_reference_char(*c)) {
return Err(InvalidImageRef::Character(bad));
}
Ok(Self(raw.to_owned()))
}
/// Borrows the validated reference.
pub fn as_str(&self) -> &str {
&self.0
}
}
const fn is_reference_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@' | '+')
}
impl TryFrom<String> for ImageRef {
type Error = InvalidImageRef;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<ImageRef> for String {
fn from(value: ImageRef) -> Self {
value.0
}
}
impl fmt::Display for ImageRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// An OCI image repository without a tag or digest, such as
/// `ghcr.io/basecamp/writebook`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageSource(String);
impl ImageSource {
/// Parses an unversioned OCI image repository.
pub fn parse(raw: &str) -> Result<Self, InvalidImageSource> {
if raw.is_empty() || raw.len() > 383 {
return Err(InvalidImageSource::Length(raw.len()));
}
if raw.starts_with('-') {
return Err(InvalidImageSource::LeadingDash);
}
if let Some(bad) = raw.chars().find(|c| !is_reference_char(*c)) {
return Err(InvalidImageSource::Character(bad));
}
if raw.contains('@')
|| raw
.rsplit('/')
.next()
.is_some_and(|part| part.contains(':'))
{
return Err(InvalidImageSource::Versioned);
}
if raw.starts_with('/') || raw.ends_with('/') || raw.split('/').any(str::is_empty) {
return Err(InvalidImageSource::Path);
}
Ok(Self(raw.to_owned()))
}
/// Borrows the validated repository.
pub fn as_str(&self) -> &str {
&self.0
}
/// Combines this repository with its separately declared version.
pub fn into_image(mut self, version: &ImageVersion) -> ImageRef {
self.0.reserve(version.0.len().saturating_add(1));
self.0.push(':');
self.0.push_str(&version.0);
ImageRef(self.0)
}
}
impl TryFrom<String> for ImageSource {
type Error = InvalidImageSource;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<ImageSource> for String {
fn from(value: ImageSource) -> Self {
value.0
}
}
impl fmt::Display for ImageSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// An OCI image tag used as the declared application version.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageVersion(String);
impl ImageVersion {
/// Parses an OCI tag.
pub fn parse(raw: &str) -> Result<Self, InvalidImageVersion> {
if raw.is_empty() || raw.len() > 128 {
return Err(InvalidImageVersion::Length(raw.len()));
}
let mut chars = raw.chars();
if !chars
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(InvalidImageVersion::LeadingCharacter);
}
if let Some(bad) =
chars.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')))
{
return Err(InvalidImageVersion::Character(bad));
}
Ok(Self(raw.to_owned()))
}
/// Borrows the validated version tag.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for ImageVersion {
type Error = InvalidImageVersion;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value)
}
}
impl From<ImageVersion> for String {
fn from(value: ImageVersion) -> Self {
value.0
}
}
impl fmt::Display for ImageVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// The Docker container name backing an application. Display-only; never
/// forwarded to a command line.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContainerName(pub String);
impl fmt::Display for ContainerName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Lifecycle state of the container backing an application.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContainerState {
/// Created but never started.
Created,
/// Serving traffic.
Running,
/// Frozen, typically mid-backup.
Paused,
/// Restarting after an exit.
Restarting,
/// Being torn down by the daemon.
Removing,
/// Stopped.
Exited,
/// The daemon considers the container unrecoverable.
Dead,
/// The daemon reported a state this build does not know.
Unknown,
}
impl ContainerState {
/// Maps a Docker `State` string onto a known variant.
pub fn from_docker(raw: &str) -> Self {
match raw {
"created" => Self::Created,
"running" => Self::Running,
"paused" => Self::Paused,
"restarting" => Self::Restarting,
"removing" => Self::Removing,
"exited" => Self::Exited,
"dead" => Self::Dead,
_ => Self::Unknown,
}
}
/// Human label for tables and logs.
pub const fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::Running => "running",
Self::Paused => "paused",
Self::Restarting => "restarting",
Self::Removing => "removing",
Self::Exited => "exited",
Self::Dead => "dead",
Self::Unknown => "unknown",
}
}
}
/// One application as `twice` sees it, assembled from the Docker daemon.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSummary {
/// Hostname `once` addresses the application by.
pub host: Host,
/// Backing container name.
pub container: ContainerName,
/// Image the container currently runs.
pub image: ImageRef,
/// Lifecycle state.
pub state: ContainerState,
/// Human-readable status line straight from the daemon, e.g. `Up 6 hours`.
pub status: String,
/// Whether `once` was told to auto-update this app, when the label says so.
pub auto_update: Option<bool>,
/// Whether `once` was told to auto-backup this app, when the label says so.
pub auto_backup: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_accepts_a_normal_dns_name() {
assert_eq!(
Host::parse("writebook.example.com").unwrap().as_str(),
"writebook.example.com"
);
}
#[test]
fn host_rejects_flag_shaped_and_shell_shaped_input() {
for hostile in [
"--image",
"-n",
"a b",
"a;rm -rf /",
"a/../b",
"$(id)",
"a|b",
"",
] {
assert!(
Host::parse(hostile).is_err(),
"accepted hostile host {hostile:?}"
);
}
}
#[test]
fn host_rejects_empty_and_misplaced_labels() {
for bad in ["a..b", ".a", "a.", "a-", "-a"] {
assert_eq!(
Host::parse(bad),
Err(InvalidHost::Label),
"accepted {bad:?}"
);
}
}
#[test]
fn image_ref_accepts_registry_port_tag_and_digest() {
let raw = "ghcr.io:443/basecamp/writebook@sha256:abc123";
assert_eq!(ImageRef::parse(raw).unwrap().as_str(), raw);
}
#[test]
fn image_ref_rejects_flag_shaped_and_shell_shaped_input() {
for hostile in [
"--env", "-x", "img ref", "img;id", "img$(id)", "img\nrm", "",
] {
assert!(
ImageRef::parse(hostile).is_err(),
"accepted hostile image {hostile:?}"
);
}
}
#[test]
fn source_and_version_form_a_pinned_image() {
let source = ImageSource::parse("registry.example.com:5000/team/app").unwrap();
let version = ImageVersion::parse("v1.2.3-rc.1").unwrap();
assert_eq!(
source.into_image(&version).as_str(),
"registry.example.com:5000/team/app:v1.2.3-rc.1"
);
}
#[test]
fn source_requires_version_to_be_separate() {
for versioned in ["ghcr.io/team/app:1.2.3", "ghcr.io/team/app@sha256:abc"] {
assert_eq!(
ImageSource::parse(versioned),
Err(InvalidImageSource::Versioned)
);
}
}
#[test]
fn version_uses_the_oci_tag_alphabet() {
assert!(ImageVersion::parse("1.2.3").is_ok());
for bad in ["", "-latest", "feature/latest", "version with spaces"] {
assert!(
ImageVersion::parse(bad).is_err(),
"accepted version {bad:?}"
);
}
}
#[test]
fn container_state_maps_unknown_daemon_strings() {
assert_eq!(
ContainerState::from_docker("running"),
ContainerState::Running
);
assert_eq!(
ContainerState::from_docker("teleporting"),
ContainerState::Unknown
);
}
}