use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LayerDigest {
pub digest: String,
pub command: String,
pub created: String,
pub is_empty: bool,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestTracker {
pub layer_digests: Vec<LayerDigest>,
}
impl DigestTracker {
pub fn new() -> Self {
Self {
layer_digests: Vec::new(),
}
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(path).context("Failed to read Image.md")?;
let image_metadata = crate::image_metadata::ImageMetadata::parse_markdown(&content)
.context("Failed to parse Image.md")?;
let tracker = Self {
layer_digests: image_metadata.layer_digests,
};
Ok(tracker)
}
pub fn add_layer(
&mut self,
position: usize,
digest: String,
command: String,
created: String,
is_empty: bool,
comment: Option<String>,
) {
let layer_digest = LayerDigest {
digest,
command,
created,
is_empty,
comment,
};
assert_eq!(
position,
self.layer_digests.len(),
"Layers must be added sequentially. Expected position {}, got {}",
self.layer_digests.len(),
position
);
self.layer_digests.push(layer_digest);
}
pub fn get_layer(&self, position: usize) -> Option<&LayerDigest> {
self.layer_digests.get(position)
}
pub fn layer_matches(&self, position: usize, layer: &crate::extracted_image::Layer) -> bool {
if let Some(existing_layer) = self.get_layer(position) {
self.layers_match(existing_layer, layer)
} else {
false
}
}
fn layers_match(&self, existing: &LayerDigest, new: &crate::extracted_image::Layer) -> bool {
if existing.is_empty != new.is_empty {
return false;
}
let new_timestamp = new.created_at.to_rfc3339();
let existing_normalized = existing.created.replace("Z", "+00:00");
let new_normalized = new_timestamp.replace("Z", "+00:00");
if existing_normalized != new_normalized {
return false;
}
if existing.is_empty {
existing.command == new.command
} else {
let new_digest = Self::extract_digest_from_layer_id(&new.id);
existing.digest == new_digest
}
}
fn extract_digest_from_layer_id(layer_id: &str) -> String {
if layer_id.starts_with("sha256:") {
layer_id.to_string()
} else if layer_id.starts_with('<') && layer_id.ends_with('>') {
layer_id.to_string()
} else {
format!("sha256:{layer_id}")
}
}
pub fn extract_digest_from_tarball_path<P: AsRef<Path>>(tarball_path: P) -> String {
let path = tarball_path.as_ref();
if let Some(parent) = path.parent() {
if parent.file_name().map(|s| s.to_str()) == Some(Some("sha256")) {
if let Some(digest) = path.file_name().and_then(|s| s.to_str()) {
return format!("sha256:{digest}");
}
}
}
if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
if filename.starts_with("sha256:") {
filename.to_string()
} else {
format!("sha256:{filename}")
}
} else {
"unknown".to_string()
}
}
}
impl Default for DigestTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use tempfile::tempdir;
#[test]
fn test_digest_tracker_creation() {
let tracker = DigestTracker::new();
assert_eq!(tracker.layer_digests.len(), 0);
assert!(tracker.layer_digests.is_empty());
}
#[test]
fn test_add_and_get_layer() {
let mut tracker = DigestTracker::new();
tracker.add_layer(
0,
"sha256:abc123".to_string(),
"FROM alpine".to_string(),
"2023-01-01T00:00:00Z".to_string(),
false,
None,
);
let layer = tracker.get_layer(0).unwrap();
assert_eq!(layer.digest, "sha256:abc123");
assert_eq!(layer.command, "FROM alpine");
assert!(!layer.is_empty);
}
#[test]
fn test_load_from_image_md() {
let temp_dir = tempdir().unwrap();
let image_md_path = temp_dir.path().join("Image.md");
let image_md_content = r#"# Image: test:latest
## Basic Information
- **Name**: test:latest
- **ID**: `sha256:test123`
## Layer History
| Created | Command | Comment | Digest | Empty |
|---------|---------|---------|--------|-------|
| 2023-01-01T00:00:00Z | `FROM alpine` | buildkit.dockerfile.v0 | `sha256:abc123` | false |
"#;
std::fs::write(&image_md_path, image_md_content).unwrap();
let loaded_tracker = DigestTracker::load_from_file(&image_md_path).unwrap();
assert_eq!(loaded_tracker.layer_digests.len(), 1);
let layer = loaded_tracker.get_layer(0).unwrap();
assert_eq!(layer.digest, "sha256:abc123");
assert_eq!(layer.command, "FROM alpine");
assert!(!layer.is_empty);
}
#[test]
fn test_extract_digest_from_tarball_path() {
let digest1 = DigestTracker::extract_digest_from_tarball_path("blobs/sha256/abc123def456");
assert_eq!(digest1, "sha256:abc123def456");
let digest2 = DigestTracker::extract_digest_from_tarball_path("abc123def456");
assert_eq!(digest2, "sha256:abc123def456");
let digest3 = DigestTracker::extract_digest_from_tarball_path("sha256:abc123def456");
assert_eq!(digest3, "sha256:abc123def456");
}
#[test]
fn test_layer_matches() {
let mut tracker = DigestTracker::new();
tracker.add_layer(
0,
"sha256:layer1".to_string(),
"FROM alpine".to_string(),
"2023-01-01T00:00:00Z".to_string(),
false,
None,
);
tracker.add_layer(
1,
"sha256:layer2".to_string(),
"RUN apk add curl".to_string(),
"2023-01-01T01:00:00Z".to_string(),
false,
None,
);
tracker.add_layer(
2,
"empty".to_string(),
"ENV PATH=/bin".to_string(),
"2023-01-01T02:00:00Z".to_string(),
true,
None,
);
let matching_layer1 = crate::extracted_image::Layer {
id: "layer1".to_string(),
command: "FROM alpine".to_string(),
created_at: chrono::DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
is_empty: false,
tarball_path: Some(std::path::PathBuf::from("layer1.tar")),
digest: "sha256:layer1".to_string(),
comment: Some("FROM alpine".to_string()),
};
assert!(tracker.layer_matches(0, &matching_layer1));
let matching_layer2 = crate::extracted_image::Layer {
id: "layer2".to_string(),
command: "RUN apk add curl".to_string(),
created_at: chrono::DateTime::parse_from_rfc3339("2023-01-01T01:00:00Z")
.unwrap()
.with_timezone(&Utc),
is_empty: false,
tarball_path: Some(std::path::PathBuf::from("layer2.tar")),
digest: "sha256:layer2".to_string(),
comment: Some("RUN apk add curl".to_string()),
};
assert!(tracker.layer_matches(1, &matching_layer2));
let non_matching_layer = crate::extracted_image::Layer {
id: "<empty-layer-2>".to_string(),
command: "ENV NEWVAR=value".to_string(), created_at: chrono::DateTime::parse_from_rfc3339("2023-01-01T02:00:00Z")
.unwrap()
.with_timezone(&Utc),
is_empty: true,
tarball_path: None,
digest: "empty".to_string(),
comment: Some("ENV NEWVAR=value".to_string()),
};
assert!(!tracker.layer_matches(2, &non_matching_layer));
let timestamp_mismatch_layer = crate::extracted_image::Layer {
id: "<empty-layer-2>".to_string(),
command: "ENV PATH=/bin".to_string(), created_at: chrono::DateTime::parse_from_rfc3339("2023-01-01T03:00:00Z")
.unwrap()
.with_timezone(&Utc), is_empty: true,
tarball_path: None,
digest: "empty".to_string(),
comment: Some("ENV PATH=/bin".to_string()),
};
assert!(!tracker.layer_matches(2, ×tamp_mismatch_layer));
assert!(!tracker.layer_matches(10, &matching_layer1));
}
}