Files
2025-08-07 12:17:59 -07:00

613 lines
23 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
import Testing
@testable import ContainerBuildIR
@testable import ContainerBuildReporting
struct AnalysisTests {
// MARK: - SemanticAnalyzer Integration Tests
@Test func semanticAnalyzerGraphAnalyzerProtocol() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let originalGraph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache curl")
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
.user(.uid(1000))
.entrypoint(.exec(["/app/server"]))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let analyzedGraph = try analyzer.analyze(originalGraph, context: context)
// SemanticAnalyzer should return the graph unchanged
#expect(analyzedGraph.stages.count == originalGraph.stages.count)
#expect(analyzedGraph.buildArgs == originalGraph.buildArgs)
#expect(analyzedGraph.targetPlatforms == originalGraph.targetPlatforms)
}
@Test func semanticAnalyzerWithReporter() async throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with potential issues
let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in
try builder
.run("apt-get update") // Separate update (should trigger layer warning)
.run("apt-get install -y curl") // Separate install (should trigger layer warning)
.run("wget https://example.com/script.sh") // Unverified download (security warning)
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
// No USER instruction (should trigger security warning)
}
let reporter = Reporter()
let context = AnalysisContext(reporter: reporter, sourceMap: nil)
let analyzer = SemanticAnalyzer()
let _ = try analyzer.analyze(graph, context: context)
// Allow some time for async reporting
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second
// Verify that some events were reported
// Note: We can't easily test the exact events without implementing a test reporter
// that captures events, but we can verify the analyze method completed successfully
#expect(Bool(true)) // Analysis completed without throwing
}
// MARK: - Layer Efficiency Analysis Tests
@Test func layerEfficiencyMultiplePackageManagers() throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with multiple package manager calls
let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in
try builder
.run("apt-get update")
.run("apt-get install -y curl")
.run("apt-get install -y wget")
.run("apt-get install -y git")
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
// This should trigger layer efficiency warnings
let _ = try analyzer.analyze(graph, context: context)
// Verify analysis completed successfully
#expect(Bool(true))
}
@Test func layerEfficiencyAddThenRemove() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph that adds then removes files
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache build-base")
.copyFromContext(paths: ["src/"], to: "/tmp/build/")
.run("cd /tmp/build && make")
.run("rm -rf /tmp/build") // Remove build files
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
// This should trigger layer efficiency warnings about unnecessary files
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
// MARK: - Security Analysis Tests
@Test func securityAnalysisRootUser() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph that runs as root (no USER instruction)
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache curl")
.workdir("/app")
.copyFromContext(paths: ["app"], to: "/app/")
.entrypoint(.exec(["/app/app"]))
// No USER instruction - should trigger security warning
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func securityAnalysisPrivilegedExecution() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with privileged execution
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("mount /dev/sda1 /mnt")
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func securityAnalysisUnverifiedDownloads() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with unverified downloads
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("wget https://example.com/install.sh && sh install.sh")
.run("curl -sSL https://get.docker.com | sh")
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func securityAnalysisUnpinnedDependencies() throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with unpinned dependencies
let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in
try builder
.run("apt-get update")
.run("apt-get install -y curl") // No version pinning
.run("pip install flask") // No version pinning
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
// MARK: - Cache Analysis Tests
@Test func cacheAnalysisTimestampInvalidation() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with timestamp-based cache invalidation
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache curl")
.run("echo $(date) > /app/build-time.txt") // Timestamp invalidates cache
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func cacheAnalysisRandomInvalidation() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with random data cache invalidation
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache curl")
.run("echo $RANDOM > /app/random.txt") // Random invalidates cache
.run("openssl rand -hex 16 > /app/key.txt") // Random invalidates cache
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
// MARK: - Size Optimization Tests
@Test func sizeOptimizationPackageCache() throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph without package cache cleanup
let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in
try builder
.run("apt-get update")
.run("apt-get install -y curl wget git")
// No cleanup - should trigger size optimization warning
.workdir("/app")
.user(.uid(1000))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func sizeOptimizationBuildDependencies() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a single-stage graph with build dependencies
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache build-base gcc-dev") // Build dependencies
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
.run("make")
.user(.uid(1000))
.entrypoint(.exec(["/app/server"]))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func sizeOptimizationMultiStageComparison() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a multi-stage graph (should NOT trigger build dependency warnings)
let graph = try GraphBuilder.multiStage { builder in
try builder
.stage(name: "builder", from: alpineRef)
.run("apk add --no-cache build-base")
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
.run("make")
try builder
.stage(from: alpineRef)
.copyFromStage(.named("builder"), paths: ["/app/server"], to: "/app/")
.user(.uid(1000))
.entrypoint(.exec(["/app/server"]))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
// MARK: - Custom Analyzer Tests
struct CustomSecurityAnalyzer: GraphAnalyzer {
func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph {
// Custom analysis: Check for hardcoded secrets
for stage in graph.stages {
for node in stage.nodes {
if let exec = node.operation as? ExecOperation {
if case .shell(let cmd) = exec.command {
if cmd.contains("PASSWORD=") || cmd.contains("SECRET=") {
if let reporter = context.reporter {
Task {
await reporter.report(
.irEvent(
context: ReportContext(
description: "Potential hardcoded secret detected in command",
sourceMap: nil
),
type: .error
))
}
}
}
}
}
}
}
return graph
}
}
@Test func customAnalyzerExtensibility() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with hardcoded secrets
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("export PASSWORD=secret123") // Hardcoded secret
.run("SECRET=api_key_123 ./app") // Hardcoded secret
.workdir("/app")
.user(.uid(1000))
}
let customAnalyzer = CustomSecurityAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try customAnalyzer.analyze(graph, context: context)
#expect(Bool(true))
}
// MARK: - Analyzer Chain Tests
@Test func analyzerChain() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("apk add --no-cache curl")
.workdir("/app")
.copyFromContext(paths: ["src/"], to: "/app/")
.user(.uid(1000))
.entrypoint(.exec(["/app/server"]))
}
let analyzers: [any GraphAnalyzer] = [
DependencyAnalyzer(),
SemanticAnalyzer(),
CustomSecurityAnalyzer(),
]
var currentGraph = graph
let context = AnalysisContext(reporter: nil, sourceMap: nil)
// Apply analyzers in sequence
for analyzer in analyzers {
currentGraph = try analyzer.analyze(currentGraph, context: context)
}
// Graph should be preserved through the chain
#expect(currentGraph.stages.count == graph.stages.count)
#expect(currentGraph.buildArgs == graph.buildArgs)
}
// MARK: - Performance Tests
@Test func analysisPerformanceLargeGraph() throws {
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// Create a large graph for performance testing
let graph = try GraphBuilder.multiStage { builder in
for stageIndex in 0..<10 {
try builder
.stage(name: "stage\(stageIndex)", from: alpineRef)
.run("apk add --no-cache curl")
.run("apk add --no-cache wget")
.run("apk add --no-cache git")
.workdir("/app")
.copyFromContext(paths: ["file\(stageIndex).txt"], to: "/app/")
.env("STAGE", "\(stageIndex)")
.label("stage.number", "\(stageIndex)")
.user(.uid(1000))
}
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let startTime = Date()
let _ = try analyzer.analyze(graph, context: context)
let duration = Date().timeIntervalSince(startTime)
print("Semantic analysis for large graph (10 stages): \(String(format: "%.3f", duration))s")
#expect(duration < 1.0, "Analysis should complete quickly for large graphs")
}
// MARK: - Error Handling Tests
@Test func analysisErrorHandling() throws {
// Test that analysis handles malformed operations gracefully
guard let alpineRef = ImageReference(parsing: "alpine:latest") else {
Issue.record("Failed to parse image reference")
return
}
// GraphBuilder will throw validation errors for malformed operations
#expect(throws: ValidationError.self) {
try GraphBuilder.singleStage(from: alpineRef) { builder in
try builder
.run("") // Empty command
.workdir("") // Empty workdir
.copyFromContext(paths: [], to: "") // Empty paths and destination
.user(.uid(0)) // Root user
}
}
}
// MARK: - Reporting Integration Tests
@Test func analysisReportingIntegration() async throws {
guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else {
Issue.record("Failed to parse image reference")
return
}
// Create a graph with multiple types of issues
let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in
try builder
.run("apt-get update") // Layer efficiency issue
.run("apt-get install -y curl") // Layer efficiency issue
.run("apt-get install -y wget") // Layer efficiency issue
.run("wget https://example.com/script.sh") // Security issue
.run("echo $RANDOM > /app/random.txt") // Cache invalidation issue
.workdir("/app")
// No USER instruction - security issue
// No cleanup - size optimization issue
}
let reporter = Reporter()
let context = AnalysisContext(
reporter: reporter,
sourceMap: SourceMap(
file: "Dockerfile",
line: 1,
column: 1,
snippet: "FROM ubuntu:22.04"
)
)
let analyzer = SemanticAnalyzer()
let _ = try analyzer.analyze(graph, context: context)
// Allow time for async reporting
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second
#expect(Bool(true)) // Analysis and reporting completed successfully
}
// MARK: - Real-world Scenario Tests
@Test func analysisNodeJSApplication() throws {
guard let nodeRef = ImageReference(parsing: "node:18-alpine") else {
Issue.record("Failed to parse image reference")
return
}
// Realistic Node.js application build
let graph = try GraphBuilder.singleStage(from: nodeRef) { builder in
try builder
.run("apk add --no-cache dumb-init") // Good: single package install
.workdir("/app")
.copyFromContext(paths: ["package*.json"], to: "./")
.run("npm ci --only=production && npm cache clean --force") // Good: cleanup
.copyFromContext(paths: ["src/"], to: "./src/")
.user(.uid(1000)) // Good: non-root user
.expose([PortSpec(port: 3000)])
.entrypoint(.exec(["dumb-init", "node", "src/index.js"]))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
@Test func analysisGoApplication() throws {
guard let golangRef = ImageReference(parsing: "golang:1.21-alpine"),
let alpineRef = ImageReference(parsing: "alpine:latest")
else {
Issue.record("Failed to parse image references")
return
}
// Realistic Go multi-stage build
let graph = try GraphBuilder.multiStage { builder in
try builder
.stage(name: "builder", from: golangRef)
.workdir("/src")
.copyFromContext(paths: ["go.mod", "go.sum"], to: "./")
.run("go mod download")
.copyFromContext(paths: [".", "!**/*_test.go"], to: "./")
.run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/main.go")
try builder
.stage(from: alpineRef)
.run("apk add --no-cache ca-certificates && rm -rf /var/cache/apk/*") // Good: cleanup
.copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/")
.user(.uid(65534)) // Good: nobody user
.expose([PortSpec(port: 8080)])
.entrypoint(.exec(["/usr/local/bin/app"]))
}
let analyzer = SemanticAnalyzer()
let context = AnalysisContext(reporter: nil, sourceMap: nil)
let _ = try analyzer.analyze(graph, context: context)
#expect(Bool(true))
}
}