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