hyperi_rustlib/deployment/
contract_identity.rs1use std::env;
44use std::process::Command;
45
46pub const KEY_PREFIX: &str = "io.hyperi.contract";
48
49pub const VERSION: &str = "v1";
52
53#[derive(Debug, thiserror::Error)]
55pub enum IdentityError {
56 #[error("source_commit must be 40-char lowercase hex (got {got:?})")]
58 InvalidSourceCommit {
59 got: String,
61 },
62
63 #[error(
70 "image_ref must include an explicit registry host (got {got:?}); \
71 no implicit docker.io/library/ prefix allowed"
72 )]
73 InvalidImageRef {
74 got: String,
76 },
77
78 #[error(
80 "could not auto-detect source_commit: GITHUB_SHA env var is unset \
81 or invalid, and `git rev-parse HEAD` failed: {reason}"
82 )]
83 DetectFailed {
84 reason: String,
86 },
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct ContractIdentity {
97 source_commit: String,
98 image_ref: String,
99}
100
101impl ContractIdentity {
102 pub fn new(
113 source_commit: impl Into<String>,
114 image_ref: impl Into<String>,
115 ) -> Result<Self, IdentityError> {
116 let source_commit = source_commit.into();
117 let image_ref = image_ref.into();
118 validate_source_commit(&source_commit)?;
119 validate_image_ref(&image_ref)?;
120 Ok(Self {
121 source_commit,
122 image_ref,
123 })
124 }
125
126 pub fn detect(image_ref: impl Into<String>) -> Result<Self, IdentityError> {
140 let source_commit = detect_source_commit()?;
141 Self::new(source_commit, image_ref)
142 }
143
144 #[must_use]
146 pub fn version(&self) -> &'static str {
147 VERSION
148 }
149
150 #[must_use]
152 pub fn source_commit(&self) -> &str {
153 &self.source_commit
154 }
155
156 #[must_use]
158 pub fn image_ref(&self) -> &str {
159 &self.image_ref
160 }
161
162 #[must_use]
166 pub fn as_dockerfile_labels(&self) -> String {
167 format!(
168 "LABEL {KEY_PREFIX}.version=\"{VERSION}\"\n\
169 LABEL {KEY_PREFIX}.source-commit=\"{c}\"\n\
170 LABEL {KEY_PREFIX}.image-ref=\"{r}\"",
171 c = self.source_commit,
172 r = self.image_ref,
173 )
174 }
175
176 #[must_use]
181 pub fn as_yaml_annotations(&self, indent: usize) -> String {
182 let pad = " ".repeat(indent);
183 format!(
184 "{pad}{KEY_PREFIX}.version: \"{VERSION}\"\n\
185 {pad}{KEY_PREFIX}.source-commit: \"{c}\"\n\
186 {pad}{KEY_PREFIX}.image-ref: \"{r}\"",
187 c = self.source_commit,
188 r = self.image_ref,
189 )
190 }
191}
192
193fn validate_source_commit(s: &str) -> Result<(), IdentityError> {
194 if s.len() != 40 || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
195 return Err(IdentityError::InvalidSourceCommit { got: s.to_string() });
196 }
197 Ok(())
198}
199
200fn validate_image_ref(s: &str) -> Result<(), IdentityError> {
201 if s.is_empty() {
202 return Err(IdentityError::InvalidImageRef { got: s.to_string() });
203 }
204 let Some((host, _rest)) = s.split_once('/') else {
206 return Err(IdentityError::InvalidImageRef { got: s.to_string() });
207 };
208 if host == "localhost" || host.contains('.') || host.contains(':') {
212 Ok(())
213 } else {
214 Err(IdentityError::InvalidImageRef { got: s.to_string() })
215 }
216}
217
218fn detect_source_commit() -> Result<String, IdentityError> {
219 if let Ok(sha) = env::var("GITHUB_SHA") {
221 let sha = sha.trim().to_lowercase();
222 if validate_source_commit(&sha).is_ok() {
223 return Ok(sha);
224 }
225 }
226 let output = Command::new("git")
228 .args(["rev-parse", "HEAD"])
229 .output()
230 .map_err(|e| IdentityError::DetectFailed {
231 reason: e.to_string(),
232 })?;
233 if !output.status.success() {
234 return Err(IdentityError::DetectFailed {
235 reason: String::from_utf8_lossy(&output.stderr).trim().to_string(),
236 });
237 }
238 let sha = String::from_utf8_lossy(&output.stdout)
239 .trim()
240 .to_lowercase();
241 validate_source_commit(&sha)?;
242 Ok(sha)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 const VALID_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
250
251 #[test]
252 fn new_accepts_valid_inputs() {
253 let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
254 assert_eq!(id.version(), "v1");
255 assert_eq!(id.source_commit(), VALID_SHA);
256 assert_eq!(id.image_ref(), "ghcr.io/hyperi-io/dfe-loader:v2.7.2");
257 }
258
259 #[test]
260 fn new_rejects_short_source_commit() {
261 let err = ContractIdentity::new("abc123", "ghcr.io/x/y:v1").unwrap_err();
262 assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
263 }
264
265 #[test]
266 fn new_rejects_uppercase_source_commit() {
267 let upper = "0123456789ABCDEF0123456789ABCDEF01234567";
268 let err = ContractIdentity::new(upper, "ghcr.io/x/y:v1").unwrap_err();
269 assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
270 }
271
272 #[test]
273 fn new_rejects_source_commit_with_sha256_prefix() {
274 let prefixed = format!("sha256:{VALID_SHA}");
275 let err = ContractIdentity::new(prefixed, "ghcr.io/x/y:v1").unwrap_err();
276 assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
277 }
278
279 #[test]
280 fn new_rejects_empty_image_ref() {
281 let err = ContractIdentity::new(VALID_SHA, "").unwrap_err();
282 assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
283 }
284
285 #[test]
286 fn new_rejects_bare_docker_hub_shortcut() {
287 let err = ContractIdentity::new(VALID_SHA, "nginx:1.25").unwrap_err();
289 assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
290 }
291
292 #[test]
293 fn new_rejects_bare_repo_path_without_host() {
294 let err = ContractIdentity::new(VALID_SHA, "library/nginx:1.25").unwrap_err();
296 assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
297 }
298
299 #[test]
300 fn new_accepts_localhost_registry() {
301 let id = ContractIdentity::new(VALID_SHA, "localhost:5000/dfe-loader:test").unwrap();
304 assert_eq!(id.image_ref(), "localhost:5000/dfe-loader:test");
305 }
306
307 #[test]
308 fn new_accepts_digest_form() {
309 let digest_ref = format!("ghcr.io/hyperi-io/dfe-loader@sha256:{VALID_SHA}");
310 let id = ContractIdentity::new(VALID_SHA, digest_ref.clone()).unwrap();
311 assert_eq!(id.image_ref(), digest_ref);
312 }
313
314 #[test]
315 fn dockerfile_labels_canonical_order_and_quoting() {
316 let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
317 let out = id.as_dockerfile_labels();
318 assert_eq!(
319 out,
320 "LABEL io.hyperi.contract.version=\"v1\"\n\
321 LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\"\n\
322 LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
323 );
324 }
325
326 #[test]
327 fn yaml_annotations_canonical_order_and_quoting() {
328 let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
329 let out = id.as_yaml_annotations(4);
330 assert_eq!(
331 out,
332 " io.hyperi.contract.version: \"v1\"\n \
333 io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\"\n \
334 io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
335 );
336 }
337
338 #[test]
339 fn yaml_annotations_zero_indent() {
340 let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
341 let out = id.as_yaml_annotations(0);
342 assert!(out.starts_with("io.hyperi.contract.version: \"v1\""));
343 }
344
345 #[test]
346 fn key_prefix_is_grep_target() {
347 let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
352 let dockerfile = id.as_dockerfile_labels();
353 let yaml = id.as_yaml_annotations(2);
354 assert_eq!(dockerfile.matches(KEY_PREFIX).count(), 3);
355 assert_eq!(yaml.matches(KEY_PREFIX).count(), 3);
356 }
357}