//===----------------------------------------------------------------------===// // 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)) } }