diff --git a/cmd/caib/main.go b/cmd/caib/main.go index f5cee5ba..a562665f 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -831,27 +831,38 @@ func pullOCIArtifact(ociRef, destPath, username, password string) error { fmt.Printf("\nExtracting artifact to %s\n", destPath) - // Extract the artifact blob to the destination file + // Extract the artifact blob(s) to the destination if err := extractOCIArtifactBlob(tempDir, destPath); err != nil { return fmt.Errorf("extract artifact: %w", err) } - // Check if file is compressed and add appropriate extension if needed - finalPath := destPath - compression := detectFileCompression(destPath) - if compression != "" && !hasCompressionExtension(destPath) { - ext := compressionExtension(compression) - if ext != "" { - newPath := destPath + ext - fmt.Printf("Adding compression extension: %s -> %s\n", filepath.Base(destPath), filepath.Base(newPath)) - if err := os.Rename(destPath, newPath); err != nil { - return fmt.Errorf("rename file with compression extension: %w", err) + // Check if destPath is a directory (multi-layer) or file (single-layer) + info, err := os.Stat(destPath) + if err != nil { + return fmt.Errorf("stat destination: %w", err) + } + + if info.IsDir() { + // Multi-layer: files already extracted with correct names + fmt.Printf("Downloaded multi-layer artifact to %s/\n", destPath) + } else { + // Single-layer: check if file is compressed and add appropriate extension if needed + finalPath := destPath + compression := detectFileCompression(destPath) + if compression != "" && !hasCompressionExtension(destPath) { + ext := compressionExtension(compression) + if ext != "" { + newPath := destPath + ext + fmt.Printf("Adding compression extension: %s -> %s\n", filepath.Base(destPath), filepath.Base(newPath)) + if err := os.Rename(destPath, newPath); err != nil { + return fmt.Errorf("rename file with compression extension: %w", err) + } + finalPath = newPath } - finalPath = newPath } + fmt.Printf("Downloaded to %s\n", finalPath) } - fmt.Printf("Downloaded to %s\n", finalPath) return nil } @@ -885,8 +896,10 @@ func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { } var manifest struct { - Layers []struct { - Digest string `json:"digest"` + Annotations map[string]string `json:"annotations"` + Layers []struct { + Digest string `json:"digest"` + Annotations map[string]string `json:"annotations"` } `json:"layers"` } if err := json.Unmarshal(manifestData, &manifest); err != nil { @@ -897,14 +910,125 @@ func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { return fmt.Errorf("no layers found in manifest") } - // Extract the first layer (should contain the disk image) + // Check if this is a multi-layer artifact + isMultiLayer := manifest.Annotations["automotive.sdv.cloud.redhat.com/multi-layer"] == "true" + + if isMultiLayer { + // Multi-layer: extract all layers to destPath directory + fmt.Printf("Multi-layer artifact detected (%d layers)\n", len(manifest.Layers)) + + // Create destination directory + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("create destination directory: %w", err) + } + + // Track sanitized filenames to prevent silent overwrites + seenFilenames := make(map[string]struct { + layerIndex int + digest string + title string + }) + + for i, layer := range manifest.Layers { + layerDigest := strings.TrimPrefix(layer.Digest, "sha256:") + layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) + + // Get filename from annotation, fallback to layer index + originalTitle := layer.Annotations["org.opencontainers.image.title"] + + // Sanitize filename to prevent path traversal attacks + filename := sanitizeFilename(originalTitle, i) + + // Check for duplicate sanitized filenames + if prev, exists := seenFilenames[filename]; exists { + return fmt.Errorf("duplicate sanitized filename '%s' for layer %d (digest: %s, title: %s) conflicts with layer %d (digest: %s, title: %s)", + filename, i, layer.Digest, originalTitle, prev.layerIndex, prev.digest, prev.title) + } + + // Record this filename as seen + seenFilenames[filename] = struct { + layerIndex int + digest string + title string + }{ + layerIndex: i, + digest: layer.Digest, + title: originalTitle, + } + + destFile := filepath.Join(destPath, filename) + fmt.Printf(" Extracting layer %d: %s\n", i+1, filename) + + if err := copyFile(layerPath, destFile); err != nil { + return fmt.Errorf("extract layer %s: %w", filename, err) + } + } + + fmt.Printf("Extracted %d files to %s\n", len(manifest.Layers), destPath) + return nil + } + + // Single-layer: extract to destPath file (original behavior) layerDigest := strings.TrimPrefix(manifest.Layers[0].Digest, "sha256:") layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) - // Copy the layer blob to destination - src, err := os.Open(layerPath) + return copyFile(layerPath, destPath) +} + +// sanitizeFilename validates and sanitizes a filename from OCI layer annotations. +// Returns a safe filename, falling back to "layer-N.bin" if the input is invalid. +// This prevents path traversal attacks by rejecting: +// - Empty filenames +// - Absolute paths +// - Paths containing ".." components +// - Paths containing null bytes +// - Filenames that differ from their base name (contain path separators) +func sanitizeFilename(filename string, layerIndex int) string { + fallback := fmt.Sprintf("layer-%d.bin", layerIndex) + + // Reject empty filenames + if filename == "" { + return fallback + } + + // Reject filenames containing null bytes + if strings.ContainsRune(filename, 0) { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains null bytes, using fallback\n", layerIndex) + return fallback + } + + // Reject absolute paths + if filepath.IsAbs(filename) { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename is absolute path, using fallback\n", layerIndex) + return fallback + } + + // Reject paths containing ".." + if strings.Contains(filename, "..") { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains '..', using fallback\n", layerIndex) + return fallback + } + + // Extract base name and reject if it differs (contains path separators) + base := filepath.Base(filename) + if base != filename { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains path separators, using basename: %s\n", layerIndex, base) + filename = base + } + + // Final safety check: base should not be empty, ".", or ".." + if filename == "" || filename == "." || filename == ".." { + return fallback + } + + return filename +} + +// copyFile copies a file from src to dst +func copyFile(srcPath, dstPath string) error { + src, err := os.Open(srcPath) if err != nil { - return fmt.Errorf("open layer blob: %w", err) + return fmt.Errorf("open source: %w", err) } defer func() { if err := src.Close(); err != nil { @@ -912,9 +1036,9 @@ func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { } }() - dst, err := os.Create(destPath) + dst, err := os.Create(dstPath) if err != nil { - return fmt.Errorf("create destination file: %w", err) + return fmt.Errorf("create destination: %w", err) } defer func() { if err := dst.Close(); err != nil { @@ -923,7 +1047,7 @@ func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { }() if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("copy layer blob: %w", err) + return fmt.Errorf("copy data: %w", err) } return nil diff --git a/internal/common/tasks/scripts/build_image.sh b/internal/common/tasks/scripts/build_image.sh index efffe09d..b2e15060 100644 --- a/internal/common/tasks/scripts/build_image.sh +++ b/internal/common/tasks/scripts/build_image.sh @@ -555,8 +555,14 @@ elif [ -d "$(workspaces.shared-workspace.path)/${exportFile}" ]; then [ -e "$item" ] || continue base=$(basename "$item") if [ -f "$item" ]; then - echo "Creating $parts_dir/${base}${EXT_FILE}" + # Record uncompressed size before compression (for OCI layer annotations) + uncompressed_size=$(stat -c%s "$item" 2>/dev/null || stat -f%z "$item" 2>/dev/null || echo "") + echo "Creating $parts_dir/${base}${EXT_FILE} (uncompressed: ${uncompressed_size:-unknown} bytes)" compress_file "$item" "$parts_dir/${base}${EXT_FILE}" || echo "Failed to create $parts_dir/${base}${EXT_FILE}" + # Store uncompressed size in sidecar file for push_artifact.sh + if [ -n "$uncompressed_size" ]; then + echo "$uncompressed_size" > "$parts_dir/${base}${EXT_FILE}.size" + fi elif [ -d "$item" ]; then echo "Creating $parts_dir/${base}${EXT_DIR}" tar_dir "${exportFile}/$base" "$parts_dir/${base}${EXT_DIR}" || echo "Failed to create $parts_dir/${base}${EXT_DIR}" diff --git a/internal/common/tasks/scripts/push_artifact.sh b/internal/common/tasks/scripts/push_artifact.sh index bd437062..5d72f707 100644 --- a/internal/common/tasks/scripts/push_artifact.sh +++ b/internal/common/tasks/scripts/push_artifact.sh @@ -1,53 +1,242 @@ #!/bin/sh set -e -# Trim whitespace/newlines from the filename +# Get media type based on file format and compression +get_media_type() { + case "$1" in + *.tar.gz) echo "application/vnd.oci.image.layer.v1.tar+gzip" ;; + *.tar.lz4) echo "application/vnd.oci.image.layer.v1.tar+lz4" ;; + *.tar.xz) echo "application/vnd.oci.image.layer.v1.tar+xz" ;; + *.tar) echo "application/vnd.oci.image.layer.v1.tar" ;; + + *.simg.gz) echo "application/vnd.automotive.disk.simg+gzip" ;; + *.simg.lz4) echo "application/vnd.automotive.disk.simg+lz4" ;; + *.simg.xz) echo "application/vnd.automotive.disk.simg+xz" ;; + *.raw.gz|*.img.gz) echo "application/vnd.automotive.disk.raw+gzip" ;; + *.raw.lz4|*.img.lz4) echo "application/vnd.automotive.disk.raw+lz4" ;; + *.raw.xz|*.img.xz) echo "application/vnd.automotive.disk.raw+xz" ;; + *.qcow2.gz) echo "application/vnd.automotive.disk.qcow2+gzip" ;; + *.qcow2.lz4) echo "application/vnd.automotive.disk.qcow2+lz4" ;; + *.qcow2.xz) echo "application/vnd.automotive.disk.qcow2+xz" ;; + + *.simg) echo "application/vnd.automotive.disk.simg" ;; + *.raw|*.img) echo "application/vnd.automotive.disk.raw" ;; + *.qcow2) echo "application/vnd.automotive.disk.qcow2" ;; + + *.gz) echo "application/gzip" ;; + *.lz4) echo "application/x-lz4" ;; + *.xz) echo "application/x-xz" ;; + + # Default fallback + *) echo "application/octet-stream" ;; + esac +} + +# Safely escape string for JSON (escape quotes, backslashes, control chars) +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g; s/\n/\\n/g; s/\r/\\r/g' +} + +get_artifact_type() { + case "$1" in + *.simg.gz|*.simg.lz4|*.simg) echo "application/vnd.automotive.disk.simg" ;; + *.qcow2.gz|*.qcow2.lz4|*.qcow2.xz|*.qcow2) echo "application/vnd.automotive.disk.qcow2" ;; + *.raw.gz|*.raw.lz4|*.raw.xz|*.raw|*.img.gz|*.img.lz4|*.img.xz|*.img) echo "application/vnd.automotive.disk.raw" ;; + *) echo "application/octet-stream" ;; + esac +} + +get_partition_name() { + # Strip base extension (.simg/.raw/.img), optional .tar, and optional compression (.gz/.lz4/.xz) + # Examples: boot_a.simg.gz -> boot_a, foo.simg.tar.gz -> foo, system.raw.lz4 -> system + basename "$1" | sed -E 's/\.(simg|raw|img)(\.tar)?(\.(gz|lz4|xz))?$//' +} + +# Get decompressed file size from sidecar .size file (created by build_image.sh) +# Falls back to empty string if sidecar doesn't exist +get_decompressed_size() { + file="$1" + size_file="${file}.size" + if [ -f "$size_file" ]; then + cat "$size_file" + else + echo "" + fi +} + exportFile=$(echo "$(params.artifact-filename)" | tr -d '[:space:]') if [ -z "$exportFile" ]; then echo "ERROR: artifact-filename param is empty" - echo "Available files in workspace:" ls -la /workspace/shared/ exit 1 fi -if [ ! -f "$exportFile" ]; then - echo "ERROR: Artifact file not found: $exportFile" - echo "Available files in workspace:" - ls -la /workspace/shared/ - exit 1 -fi +repo_url="$(params.repository-url)" +parts_dir="${exportFile}-parts" +distro="$(params.distro)" +target="$(params.target)" +arch="$(params.arch)" + +cd /workspace/shared + +echo "=== Artifact Push Configuration ===" +echo " Working directory: $(pwd)" +echo " Artifact file: ${exportFile}" +echo " Parts directory: ${parts_dir}" +echo " Repository URL: ${repo_url}" +echo " Distro: ${distro}, Target: ${target}, Arch: ${arch}" +echo "" + +if [ -d "${parts_dir}" ] && [ -n "$(ls -A "${parts_dir}" 2>/dev/null)" ]; then + echo "Found parts directory: ${parts_dir}" + echo "Using multi-layer push for individual partition files" + ls -la "${parts_dir}/" + + cd "${parts_dir}" + + # Create annotations file in current directory (ORAS container may not have /tmp) + annotations_file="./oras-annotations.json" + trap 'rm -f "$annotations_file"' EXIT + + layer_args="" + file_list="" + + layer_annotations_json="" + + for part_file in *; do + # Skip .size sidecar files + case "$part_file" in *.size) continue ;; esac + + if [ -f "$part_file" ]; then + filename="$part_file" + part_media_type=$(get_media_type "$filename") + partition_name=$(get_partition_name "$filename") + decompressed_size=$(get_decompressed_size "$filename") -case "$exportFile" in - *.tar.gz) - mediaType="application/gzip" - ;; - *.gz) - mediaType="application/gzip" - ;; - *.lz4) - mediaType="application/x-lz4" - ;; - *.xz) - mediaType="application/x-xz" - ;; - *.qcow2) - mediaType="application/x-qcow2" - ;; - *.raw|*.img) - mediaType="application/x-raw-disk-image" - ;; - *) - mediaType="application/octet-stream" - ;; -esac - -echo "Pushing artifact to $(params.repository-url)" -echo "File: ${exportFile}" -echo "Media type: ${mediaType}" - -oras push --disable-path-validation \ - $(params.repository-url) \ - ${exportFile}:${mediaType} - -echo "Artifact pushed successfully to registry" + echo " Layer: ${filename} (partition: ${partition_name}, type: ${part_media_type}, decompressed: ${decompressed_size:-unknown})" + + # Build layer argument: file:media-type (no path prefix = flat extraction) + layer_args="${layer_args} ${filename}:${part_media_type}" + + # Build comma-separated file list for parts annotation + if [ -z "$file_list" ]; then + file_list="${filename}" + else + file_list="${file_list},${filename}" + fi + + # Build per-layer annotation JSON entry with safe escaping + # Include partition name, decompressed size, and standard OCI title + if [ -n "$layer_annotations_json" ]; then + layer_annotations_json="${layer_annotations_json}," + fi + + # Safely escape values for JSON + escaped_filename=$(json_escape "$filename") + escaped_partition=$(json_escape "$partition_name") + escaped_decompressed_size=$(json_escape "$decompressed_size") + + # Build JSON with properly escaped values + if [ -n "$decompressed_size" ]; then + layer_annotations_json="${layer_annotations_json}\"${escaped_filename}\":{\"automotive.sdv.cloud.redhat.com/partition\":\"${escaped_partition}\",\"org.opencontainers.image.title\":\"${escaped_filename}\",\"automotive.sdv.cloud.redhat.com/decompressed-size\":\"${escaped_decompressed_size}\"}" + else + layer_annotations_json="${layer_annotations_json}\"${escaped_filename}\":{\"automotive.sdv.cloud.redhat.com/partition\":\"${escaped_partition}\",\"org.opencontainers.image.title\":\"${escaped_filename}\"}" + fi + fi + done + + # Guard: fail fast if no partition files were found + if [ -z "$file_list" ]; then + echo "ERROR: No partition files found in ${parts_dir}" >&2 + echo " Expected .simg, .raw, or .img files but directory appears empty or contains no regular files" >&2 + ls -la . >&2 || true + exit 1 + fi + + # Get artifact type from first entry in filtered file_list (avoids sidecar .size files) + first_filename=$(echo "$file_list" | cut -d',' -f1) + artifact_type=$(get_artifact_type "$first_filename") + + cat > "$annotations_file" </dev/null | grep -v '/$' | xargs -I{} basename {} | sort | tr '\n' ',' | sed 's/,$//') + if [ -n "$file_list" ]; then + echo " Contents: ${file_list}" + annotation_args="--annotation automotive.sdv.cloud.redhat.com/parts=${file_list}" + fi + fi + + echo "Pushing single-file artifact to ${repo_url}" + echo " File: ${exportFile}" + echo " Media type: ${media_type}" + echo " Annotations: distro=${distro}, target=${target}, arch=${arch}" + + oras push --disable-path-validation \ + --artifact-type "${media_type}" \ + --annotation "automotive.sdv.cloud.redhat.com/distro=${distro}" \ + --annotation "automotive.sdv.cloud.redhat.com/target=${target}" \ + --annotation "automotive.sdv.cloud.redhat.com/arch=${arch}" \ + ${annotation_args} \ + "${repo_url}" \ + "${exportFile}:${media_type}" + + echo "" + echo "=== Artifact pushed successfully ===" + echo "Pull: oras pull ${repo_url}" +fi diff --git a/internal/common/tasks/tasks.go b/internal/common/tasks/tasks.go index cb901cc9..8450feca 100644 --- a/internal/common/tasks/tasks.go +++ b/internal/common/tasks/tasks.go @@ -51,6 +51,11 @@ func GeneratePushArtifactRegistryTask(namespace string) *tektonv1.Task { Type: tektonv1.ParamTypeString, Description: "Build target", }, + { + Name: "arch", + Type: tektonv1.ParamTypeString, + Description: "Target architecture", + }, { Name: "export-format", Type: tektonv1.ParamTypeString, @@ -871,6 +876,13 @@ func GenerateTektonPipeline(name, namespace string) *tektonv1.Pipeline { StringVal: "$(params.target)", }, }, + { + Name: "arch", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.arch)", + }, + }, { Name: "export-format", Value: tektonv1.ParamValue{ diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 39cb0fe6..1b833cff 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -671,6 +671,13 @@ func (r *ImageBuildReconciler) createPushTaskRun(ctx context.Context, imageBuild pushTask := tasks.GeneratePushArtifactRegistryTask(OperatorNamespace) params := []tektonv1.Param{ + { + Name: "arch", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: imageBuild.Spec.Architecture, + }, + }, { Name: "distro", Value: tektonv1.ParamValue{