Skip to main content

strop_containers/
identity.rs

1//! Container identity: what `inspect` resolved, and the canonical
2//! incarnation-pinned reference every read carries.
3
4use crate::ContainerError;
5use strop_workspace::ContainerId;
6
7/// One container as the engine described it at inspect time. Plain data —
8/// resolving or re-checking it is [`crate::inspect`] /
9/// [`crate::revalidate`]'s job, never this struct's.
10#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub struct ContainerIdentity {
12    /// The canonical 64-hex id `docker inspect` reported.
13    pub id: String,
14    /// The container's name, without inspect's leading `/`.
15    pub name: String,
16    /// The image reference the container was created from (`Config.Image`).
17    pub image: String,
18    /// The incarnation marker (`State.StartedAt`, RFC 3339): a restart of
19    /// the same id changes it, so held results can be told stale cheaply.
20    pub started_at: String,
21    /// `Config.User`; empty means the image default.
22    pub user: String,
23    /// `Config.WorkingDir`; empty means the image default ("/").
24    pub workdir: String,
25}
26
27/// A canonical, incarnation-pinned reference to one running container.
28///
29/// Built only from an inspected [`ContainerIdentity`]: the 64-hex id is
30/// revalidated at construction, so a display label or name can never
31/// become an identity by accident. Every read re-checks `started_at`
32/// against the engine before touching the filesystem, so a restart or
33/// same-name recreation is a typed [`ContainerError::StaleIdentity`]
34/// rather than silently wrong bytes.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ContainerRef {
37    id: ContainerId,
38    started_at: String,
39}
40
41impl ContainerRef {
42    /// The canonical reference for an inspected identity. Refuses (as a
43    /// protocol violation) an identity whose id is not the 64-hex inspect
44    /// id — names and prefixes resolve through [`crate::inspect`] first.
45    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    /// The canonical 64-hex id — the same identity
60    /// [`strop_workspace::Filesystem::Container`] carries.
61    pub fn id(&self) -> &ContainerId {
62        &self.id
63    }
64
65    /// The incarnation marker captured at inspect time.
66    pub fn started_at(&self) -> &str {
67        &self.started_at
68    }
69
70    /// Display form of the incarnation: `id@started_at`.
71    pub(crate) fn incarnation(&self) -> String {
72        format!("{}@{}", self.id, self.started_at)
73    }
74}
75
76/// Refuse names that could inject CLI options or can never resolve.
77///
78/// Accepted: Docker's name grammar `[A-Za-z0-9][A-Za-z0-9_.-]*` (a
79/// superset that also covers hex id prefixes). Refused: empty, leading
80/// `-` (option-shaped), path separators, whitespace, control bytes and
81/// anything over 255 bytes — [`ContainerError::PoisonedName`] before the
82/// engine is ever invoked.
83pub(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}