Skip to main content

rusticity_core/ecr/
detail.rs

1//! Read-only ECR image detail reads by immutable image digest. Never starts a
2//! scan and never mutates registry state. Backs the image page's general
3//! metadata, referrers, replication status and managed signing status tabs.
4use super::EcrClient;
5use crate::pagination::{cap_results, Pagination, StopReason};
6use anyhow::{anyhow, ensure, Result};
7use aws_sdk_ecr::error::ProvideErrorMetadata;
8use aws_sdk_ecr::types::ImageIdentifier;
9use tokio::time::{timeout_at, Duration, Instant};
10
11const ENTRY_LIMIT: usize = 1_000;
12const PAGE_LIMIT: usize = 100;
13const TIMEOUT: Duration = Duration::from_secs(30);
14const MISSING: &str = "-";
15
16/// General metadata for a single image, resolved by immutable digest.
17#[derive(Debug)]
18pub struct ImageDetails {
19    pub tags: Vec<String>,
20    pub digest: String,
21    pub artifact_type: String,
22    pub manifest_type: String,
23    pub pushed_at: String,
24    pub size_bytes: Option<i64>,
25    pub last_pull_time: String,
26    pub image_status: String,
27}
28
29/// A key/value report rendered as one block per entry (referrer, replication
30/// destination, signing status). `warning` carries partial-result context,
31/// including retained AWS error codes, without failing the whole read.
32#[derive(Debug)]
33pub struct DetailReport {
34    pub entries: Vec<Vec<(String, String)>>,
35    pub warning: Option<String>,
36}
37
38impl EcrClient {
39    /// General image metadata via DescribeImages, filtered to the immutable
40    /// digest. Validates the returned digest and errors on a missing or
41    /// mismatched image rather than returning unrelated data.
42    pub async fn image_details(&self, repository: &str, digest: &str) -> Result<ImageDetails> {
43        ensure!(!repository.is_empty(), "Missing ECR repository name");
44        ensure!(
45            !digest.is_empty(),
46            "Missing image digest; refresh the image list"
47        );
48        let client = self.config.ecr_client();
49        let deadline = Instant::now() + TIMEOUT;
50        let result = timeout_at(
51            deadline,
52            client
53                .describe_images()
54                .repository_name(repository)
55                .image_ids(ImageIdentifier::builder().image_digest(digest).build())
56                .send(),
57        )
58        .await;
59        let response = match result {
60            Ok(Ok(response)) => response,
61            Ok(Err(error)) => {
62                return Err(anyhow!("Image metadata unavailable: {}", service(error)))
63            }
64            Err(_) => {
65                return Err(anyhow!(
66                    "Image metadata unavailable: request timed out after 30 seconds"
67                ))
68            }
69        };
70        let image = response
71            .image_details
72            .unwrap_or_default()
73            .into_iter()
74            .find(|image| image.image_digest.as_deref() == Some(digest))
75            .ok_or_else(|| anyhow!("No image found for digest {digest} in {repository}"))?;
76        // DescribeImages echoes the digest; guard against a shape that somehow
77        // returns a different one so the page never shows an unrelated image.
78        let returned = image.image_digest.unwrap_or_default();
79        ensure!(
80            returned == digest,
81            "ECR returned digest {returned} for requested {digest}"
82        );
83        Ok(ImageDetails {
84            tags: image.image_tags.unwrap_or_default(),
85            digest: returned,
86            artifact_type: image.artifact_media_type.unwrap_or_else(|| MISSING.into()),
87            manifest_type: image
88                .image_manifest_media_type
89                .unwrap_or_else(|| MISSING.into()),
90            pushed_at: image
91                .image_pushed_at
92                .map(timestamp)
93                .unwrap_or_else(|| MISSING.into()),
94            size_bytes: image.image_size_in_bytes,
95            last_pull_time: image
96                .last_recorded_pull_time
97                .map(timestamp)
98                .unwrap_or_else(|| MISSING.into()),
99            image_status: image
100                .image_status
101                .map(|s| s.as_str().to_string())
102                .unwrap_or_else(|| MISSING.into()),
103        })
104    }
105
106    /// Referrers (e.g. signatures, SBOMs, attestations) via ListImageReferrers,
107    /// scoped by the immutable subject digest. Paginated and bounded.
108    pub async fn image_referrers(&self, repository: &str, digest: &str) -> Result<DetailReport> {
109        let (repository, digest) = validate(repository, digest)?;
110        let client = self.config.ecr_client();
111        let deadline = Instant::now() + TIMEOUT;
112        let mut report = DetailReport {
113            entries: Vec::new(),
114            warning: None,
115        };
116        let mut token: Option<String> = None;
117        let mut pagination = Pagination::new(PAGE_LIMIT);
118        for page in 0..PAGE_LIMIT {
119            let result = timeout_at(
120                deadline,
121                client
122                    .list_image_referrers()
123                    .repository_name(repository)
124                    .subject_id(
125                        aws_sdk_ecr::types::SubjectIdentifier::builder()
126                            .image_digest(digest)
127                            .build()
128                            .map_err(|e| anyhow!("Invalid subject digest: {e}"))?,
129                    )
130                    .max_results(50)
131                    .set_next_token(token.clone())
132                    .send(),
133            )
134            .await;
135            let response = match page_result(result, page, &mut report) {
136                Some(response) => response,
137                None => break,
138            };
139            for referrer in response.referrers.unwrap_or_default() {
140                // Stable columns the image page renders: digest and artifact type.
141                let kind = referrer
142                    .artifact_type
143                    .filter(|t| !t.is_empty())
144                    .unwrap_or_else(|| nonempty(referrer.media_type));
145                report.entries.push(vec![
146                    ("Referrer digest".into(), nonempty(referrer.digest)),
147                    ("Type".into(), kind),
148                ]);
149            }
150            if cap_results(
151                &mut report.entries,
152                ENTRY_LIMIT,
153                response.next_token.as_deref(),
154            ) {
155                report.warning = Some(format!("Partial results: limited to {ENTRY_LIMIT} entries"));
156                break;
157            }
158            match pagination.advance(response.next_token) {
159                Ok(Some(next)) => token = Some(next),
160                Ok(None) => break,
161                Err(reason) => {
162                    report.warning = Some(match reason {
163                        StopReason::RepeatedToken => {
164                            "Partial results: repeated pagination token".into()
165                        }
166                        StopReason::ResultLimit => {
167                            format!("Partial results: limited to {ENTRY_LIMIT} entries")
168                        }
169                        StopReason::PageLimit => {
170                            format!("Partial results: stopped after {PAGE_LIMIT} pages")
171                        }
172                    });
173                    break;
174                }
175            }
176        }
177        Ok(report)
178    }
179
180    /// Cross-region replication status via DescribeImageReplicationStatus,
181    /// scoped by immutable digest. One entry per destination region.
182    pub async fn image_replication(&self, repository: &str, digest: &str) -> Result<DetailReport> {
183        let (repository, digest) = validate(repository, digest)?;
184        self.replication_for(
185            repository,
186            ImageIdentifier::builder().image_digest(digest).build(),
187        )
188        .await
189    }
190
191    /// Cross-region replication status scoped by a selected tag instead of a
192    /// digest. The parent's source selector uses this when the user picks a tag
193    /// as the replication source. Refuses a blank tag.
194    pub async fn image_replication_by_tag(
195        &self,
196        repository: &str,
197        tag: &str,
198    ) -> Result<DetailReport> {
199        ensure!(!repository.is_empty(), "Missing ECR repository name");
200        ensure!(!tag.is_empty(), "Missing image tag");
201        self.replication_for(
202            repository,
203            ImageIdentifier::builder().image_tag(tag).build(),
204        )
205        .await
206    }
207
208    async fn replication_for(
209        &self,
210        repository: &str,
211        image_id: ImageIdentifier,
212    ) -> Result<DetailReport> {
213        let client = self.config.ecr_client();
214        let deadline = Instant::now() + TIMEOUT;
215        let mut report = DetailReport {
216            entries: Vec::new(),
217            warning: None,
218        };
219        let result = timeout_at(
220            deadline,
221            client
222                .describe_image_replication_status()
223                .repository_name(repository)
224                .image_id(image_id)
225                .send(),
226        )
227        .await;
228        // Not paginated: a single page carries all replication statuses.
229        let response = match page_result(result, 0, &mut report) {
230            Some(response) => response,
231            None => return Ok(report),
232        };
233        for status in response.replication_statuses.unwrap_or_default() {
234            report.entries.push(vec![
235                (
236                    "Target account".into(),
237                    status.registry_id.map(nonempty).unwrap_or_else(dash),
238                ),
239                (
240                    "Region".into(),
241                    status.region.map(nonempty).unwrap_or_else(dash),
242                ),
243                (
244                    "Status".into(),
245                    status
246                        .status
247                        .map(|s| s.as_str().to_string())
248                        .unwrap_or_else(dash),
249                ),
250                (
251                    "Error".into(),
252                    status.failure_code.map(nonempty).unwrap_or_else(dash),
253                ),
254            ]);
255        }
256        Ok(report)
257    }
258
259    /// Managed signing status via DescribeImageSigningStatus, scoped by
260    /// immutable digest. One entry per signing profile.
261    pub async fn image_signing(&self, repository: &str, digest: &str) -> Result<DetailReport> {
262        let (repository, digest) = validate(repository, digest)?;
263        let client = self.config.ecr_client();
264        let deadline = Instant::now() + TIMEOUT;
265        let mut report = DetailReport {
266            entries: Vec::new(),
267            warning: None,
268        };
269        let result = timeout_at(
270            deadline,
271            client
272                .describe_image_signing_status()
273                .repository_name(repository)
274                .image_id(ImageIdentifier::builder().image_digest(digest).build())
275                .send(),
276        )
277        .await;
278        // Not paginated: a single page carries all signing statuses.
279        let response = match page_result(result, 0, &mut report) {
280            Some(response) => response,
281            None => return Ok(report),
282        };
283        for status in response.signing_statuses.unwrap_or_default() {
284            report.entries.push(vec![
285                (
286                    "Signer profile".into(),
287                    status
288                        .signing_profile_arn
289                        .map(nonempty)
290                        .unwrap_or_else(dash),
291                ),
292                (
293                    "Status".into(),
294                    status
295                        .status
296                        .map(|s| s.as_str().to_string())
297                        .unwrap_or_else(dash),
298                ),
299                (
300                    "Failure reason".into(),
301                    status.failure_reason.map(nonempty).unwrap_or_else(dash),
302                ),
303                (
304                    "Failure code".into(),
305                    status.failure_code.map(nonempty).unwrap_or_else(dash),
306                ),
307            ]);
308        }
309        Ok(report)
310    }
311}
312
313fn validate<'a>(repository: &'a str, digest: &'a str) -> Result<(&'a str, &'a str)> {
314    ensure!(!repository.is_empty(), "Missing ECR repository name");
315    ensure!(
316        !digest.is_empty(),
317        "Missing image digest; refresh the image list"
318    );
319    Ok((repository, digest))
320}
321
322/// Render a page-fetch result into a response, recording a warning and
323/// signalling a stop on error/timeout. Errors on page 0 are returned to the
324/// caller via the DetailReport warning so partials from later pages survive.
325fn page_result<T, E>(
326    result: Result<Result<T, E>, tokio::time::error::Elapsed>,
327    page: usize,
328    report: &mut DetailReport,
329) -> Option<T>
330where
331    E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
332{
333    match result {
334        Ok(Ok(response)) => Some(response),
335        Ok(Err(error)) => {
336            let detail = service(error);
337            report.warning = Some(if page == 0 {
338                format!("Unavailable: {detail}")
339            } else {
340                format!("Partial results: {detail}")
341            });
342            None
343        }
344        Err(_) => {
345            report.warning = Some(if page == 0 {
346                "Unavailable: request timed out after 30 seconds".into()
347            } else {
348                "Partial results: request timed out after 30 seconds".into()
349            });
350            None
351        }
352    }
353}
354
355fn service<E>(error: E) -> String
356where
357    E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
358{
359    if let Some(code) = error.code() {
360        format!(
361            "{}: {}",
362            code,
363            error.message().unwrap_or("No details reported")
364        )
365    } else {
366        format!("{:#}", anyhow::Error::new(error))
367    }
368}
369
370fn timestamp(date: aws_smithy_types::DateTime) -> String {
371    date.fmt(aws_smithy_types::date_time::Format::DateTime)
372        .unwrap_or_default()
373}
374
375fn nonempty(value: String) -> String {
376    if value.is_empty() {
377        MISSING.into()
378    } else {
379        value
380    }
381}
382
383fn dash() -> String {
384    MISSING.into()
385}
386
387#[cfg(test)]
388mod tests;