strop_containers/
identity.rs1use crate::ContainerError;
5use strop_workspace::ContainerId;
6
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub struct ContainerIdentity {
12 pub id: String,
14 pub name: String,
16 pub image: String,
18 pub started_at: String,
21 pub user: String,
23 pub workdir: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ContainerRef {
37 id: ContainerId,
38 started_at: String,
39}
40
41impl ContainerRef {
42 pub fn of(identity: &ContainerIdentity) -> Result<Self, ContainerError> {
46 let id =
47 ContainerId::canonical(identity.id.clone()).map_err(|_| ContainerError::Protocol {
48 detail: format!(
49 "inspect id {:?} is not the canonical 64-hex id",
50 identity.id
51 ),
52 })?;
53 Ok(Self {
54 id,
55 started_at: identity.started_at.clone(),
56 })
57 }
58
59 pub fn id(&self) -> &ContainerId {
62 &self.id
63 }
64
65 pub fn started_at(&self) -> &str {
67 &self.started_at
68 }
69
70 pub(crate) fn incarnation(&self) -> String {
72 format!("{}@{}", self.id, self.started_at)
73 }
74}
75
76pub(crate) fn validate_name(name: &str) -> Result<(), ContainerError> {
84 let valid = !name.is_empty()
85 && name.len() <= 255
86 && name
87 .bytes()
88 .next()
89 .is_some_and(|b| b.is_ascii_alphanumeric())
90 && name
91 .bytes()
92 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'));
93 if valid {
94 Ok(())
95 } else {
96 Err(ContainerError::PoisonedName {
97 name: name.to_string(),
98 })
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 fn identity(id: String) -> ContainerIdentity {
107 ContainerIdentity {
108 id,
109 name: "fixture".into(),
110 image: "busybox".into(),
111 started_at: "2026-09-10T08:00:00Z".into(),
112 user: String::new(),
113 workdir: String::new(),
114 }
115 }
116
117 #[test]
118 fn names_are_validated_before_the_engine_sees_them() {
119 assert!(validate_name("web").is_ok());
120 assert!(validate_name("web_1.2-alpine").is_ok());
121 assert!(validate_name(&"a".repeat(64)).is_ok(), "hex id");
122 assert!(validate_name("f00dbabe").is_ok(), "id prefix");
123 for bad in [
124 "", "-rf", "--format", "a b", "a/b", "a:b", ".hidden", "_lead", "é",
125 ] {
126 assert!(
127 matches!(validate_name(bad), Err(ContainerError::PoisonedName { .. })),
128 "{bad:?} must be refused"
129 );
130 }
131 assert!(validate_name(&"a".repeat(256)).is_err(), "length bound");
132 }
133
134 #[test]
135 fn references_carry_only_canonical_ids() {
136 let reference = ContainerRef::of(&identity("a".repeat(64))).unwrap();
137 assert_eq!(reference.id().as_str(), &"a".repeat(64));
138 assert_eq!(reference.started_at(), "2026-09-10T08:00:00Z");
139 assert!(ContainerRef::of(&identity("web".into())).is_err());
140 assert!(ContainerRef::of(&identity("A".repeat(64))).is_err());
141 }
142}