diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 9e0de3c809..54fac375da 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -119,6 +119,25 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath return errors.New(formattedErr) } + // Validate expressions in runtime-import files at compile time + log.Printf("Validating runtime-import files") + // Go up from .github/workflows/file.md to repo root + workflowDir := filepath.Dir(markdownPath) // .github/workflows + githubDir := filepath.Dir(workflowDir) // .github + workspaceDir := filepath.Dir(githubDir) // repo root + if err := validateRuntimeImportFiles(workflowData.MarkdownContent, workspaceDir); err != nil { + formattedErr := console.FormatError(console.CompilerError{ + Position: console.ErrorPosition{ + File: markdownPath, + Line: 1, + Column: 1, + }, + Type: "error", + Message: err.Error(), + }) + return errors.New(formattedErr) + } + // Validate feature flags log.Printf("Validating feature flags") if err := validateFeatures(workflowData); err != nil { diff --git a/pkg/workflow/expression_validation.go b/pkg/workflow/expression_validation.go index be0d8074ec..4b997df788 100644 --- a/pkg/workflow/expression_validation.go +++ b/pkg/workflow/expression_validation.go @@ -44,6 +44,8 @@ package workflow import ( "fmt" + "os" + "path/filepath" "regexp" "strings" @@ -275,3 +277,118 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption func ValidateExpressionSafetyPublic(markdownContent string) error { return validateExpressionSafety(markdownContent) } + +// extractRuntimeImportPaths extracts all runtime-import file paths from markdown content. +// Returns a list of file paths (not URLs) referenced in {{#runtime-import}} macros. +// URLs (http:// or https://) are excluded since they are validated separately. +func extractRuntimeImportPaths(markdownContent string) []string { + if markdownContent == "" { + return nil + } + + var paths []string + seen := make(map[string]bool) + + // Pattern to match {{#runtime-import filepath}} or {{#runtime-import? filepath}} + // Also handles line ranges like filepath:10-20 + macroPattern := `\{\{#runtime-import\??[ \t]+([^\}]+)\}\}` + macroRe := regexp.MustCompile(macroPattern) + matches := macroRe.FindAllStringSubmatch(markdownContent, -1) + + for _, match := range matches { + if len(match) > 1 { + pathWithRange := strings.TrimSpace(match[1]) + + // Remove line range if present (e.g., "file.md:10-20" -> "file.md") + filepath := pathWithRange + if colonIdx := strings.Index(pathWithRange, ":"); colonIdx > 0 { + // Check if what follows colon looks like a line range (digits-digits) + afterColon := pathWithRange[colonIdx+1:] + if regexp.MustCompile(`^\d+-\d+$`).MatchString(afterColon) { + filepath = pathWithRange[:colonIdx] + } + } + + // Skip URLs - they don't need file validation + if strings.HasPrefix(filepath, "http://") || strings.HasPrefix(filepath, "https://") { + continue + } + + // Add to list if not already seen + if !seen[filepath] { + paths = append(paths, filepath) + seen[filepath] = true + } + } + } + + return paths +} + +// validateRuntimeImportFiles validates expressions in all runtime-import files at compile time. +// This catches expression errors early, before the workflow runs. +// workspaceDir should be the root of the repository (containing .github folder). +func validateRuntimeImportFiles(markdownContent string, workspaceDir string) error { + expressionValidationLog.Print("Validating runtime-import files") + + // Extract all runtime-import file paths + paths := extractRuntimeImportPaths(markdownContent) + if len(paths) == 0 { + expressionValidationLog.Print("No runtime-import files to validate") + return nil + } + + expressionValidationLog.Printf("Found %d runtime-import file(s) to validate", len(paths)) + + var validationErrors []string + + for _, filePath := range paths { + // Normalize the path to be relative to .github folder + normalizedPath := filePath + if strings.HasPrefix(normalizedPath, ".github/") { + normalizedPath = normalizedPath[8:] // Remove ".github/" + } else if strings.HasPrefix(normalizedPath, ".github\\") { + normalizedPath = normalizedPath[8:] // Remove ".github\" (Windows) + } + if strings.HasPrefix(normalizedPath, "./") { + normalizedPath = normalizedPath[2:] // Remove "./" + } else if strings.HasPrefix(normalizedPath, ".\\") { + normalizedPath = normalizedPath[2:] // Remove ".\" (Windows) + } + + // Build absolute path to the file + githubFolder := filepath.Join(workspaceDir, ".github") + absolutePath := filepath.Join(githubFolder, normalizedPath) + + // Check if file exists + if _, err := os.Stat(absolutePath); os.IsNotExist(err) { + // Skip validation for optional imports ({{#runtime-import? ...}}) + // We can't determine if it's optional here, but missing files will be caught at runtime + expressionValidationLog.Printf("Skipping validation for non-existent file: %s", filePath) + continue + } + + // Read the file content + content, err := os.ReadFile(absolutePath) + if err != nil { + validationErrors = append(validationErrors, fmt.Sprintf("%s: failed to read file: %v", filePath, err)) + continue + } + + // Validate expressions in the imported file + if err := validateExpressionSafety(string(content)); err != nil { + validationErrors = append(validationErrors, fmt.Sprintf("%s: %v", filePath, err)) + } else { + expressionValidationLog.Printf("✓ Validated expressions in %s", filePath) + } + } + + if len(validationErrors) > 0 { + expressionValidationLog.Printf("Runtime-import validation failed: %d file(s) with errors", len(validationErrors)) + return fmt.Errorf("runtime-import files contain expression errors:\n\n%s", + strings.Join(validationErrors, "\n\n")) + } + + expressionValidationLog.Print("All runtime-import files validated successfully") + return nil +} diff --git a/pkg/workflow/runtime_import_validation_test.go b/pkg/workflow/runtime_import_validation_test.go new file mode 100644 index 0000000000..55c589907f --- /dev/null +++ b/pkg/workflow/runtime_import_validation_test.go @@ -0,0 +1,323 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestExtractRuntimeImportPaths tests the extractRuntimeImportPaths function +func TestExtractRuntimeImportPaths(t *testing.T) { + tests := []struct { + name string + content string + expected []string + }{ + { + name: "no imports", + content: "# Simple markdown\n\nSome text here", + expected: nil, + }, + { + name: "single file import", + content: "{{#runtime-import ./shared.md}}", + expected: []string{"./shared.md"}, + }, + { + name: "optional import", + content: "{{#runtime-import? ./optional.md}}", + expected: []string{"./optional.md"}, + }, + { + name: "import with line range", + content: "{{#runtime-import ./file.md:10-20}}", + expected: []string{"./file.md"}, + }, + { + name: "multiple imports", + content: "{{#runtime-import ./a.md}}\n{{#runtime-import ./b.md}}", + expected: []string{"./a.md", "./b.md"}, + }, + { + name: "duplicate imports", + content: "{{#runtime-import ./shared.md}}\n{{#runtime-import ./shared.md}}", + expected: []string{"./shared.md"}, // Deduplicated + }, + { + name: "URL import (should be excluded)", + content: "{{#runtime-import https://example.com/file.md}}", + expected: nil, + }, + { + name: "mixed file and URL imports", + content: "{{#runtime-import ./local.md}}\n{{#runtime-import https://example.com/remote.md}}", + expected: []string{"./local.md"}, + }, + { + name: ".github prefix in path", + content: "{{#runtime-import .github/shared/common.md}}", + expected: []string{".github/shared/common.md"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRuntimeImportPaths(tt.content) + + if tt.expected == nil { + assert.Nil(t, result, "Expected nil result") + } else { + assert.Equal(t, tt.expected, result, "Extracted paths mismatch") + } + }) + } +} + +// TestValidateRuntimeImportFiles tests the validateRuntimeImportFiles function +func TestValidateRuntimeImportFiles(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir := t.TempDir() + githubDir := filepath.Join(tmpDir, ".github") + sharedDir := filepath.Join(githubDir, "shared") + require.NoError(t, os.MkdirAll(sharedDir, 0755)) + + // Create test files with different content + validFile := filepath.Join(sharedDir, "valid.md") + validContent := `# Valid Content + +This file has safe expressions: +- Actor: ${{ github.actor }} +- Repository: ${{ github.repository }} +- Issue number: ${{ github.event.issue.number }} +` + require.NoError(t, os.WriteFile(validFile, []byte(validContent), 0644)) + + invalidFile := filepath.Join(sharedDir, "invalid.md") + invalidContent := `# Invalid Content + +This file has unsafe expressions: +- Secret: ${{ secrets.MY_TOKEN }} +- Runner: ${{ runner.os }} +` + require.NoError(t, os.WriteFile(invalidFile, []byte(invalidContent), 0644)) + + multilineFile := filepath.Join(sharedDir, "multiline.md") + multilineContent := `# Multiline Expression + +This has a multiline expression: +${{ github.actor + && github.run_id }} +` + require.NoError(t, os.WriteFile(multilineFile, []byte(multilineContent), 0644)) + + tests := []struct { + name string + markdown string + expectError bool + errorText string + }{ + { + name: "no runtime imports", + markdown: "# Simple workflow\n\nNo imports here", + expectError: false, + }, + { + name: "valid runtime import", + markdown: "{{#runtime-import ./shared/valid.md}}", + expectError: false, + }, + { + name: "invalid runtime import", + markdown: "{{#runtime-import ./shared/invalid.md}}", + expectError: true, + errorText: "secrets.MY_TOKEN", + }, + { + name: "multiline expression in import", + markdown: "{{#runtime-import ./shared/multiline.md}}", + expectError: true, + errorText: "unauthorized expressions", + }, + { + name: "multiple imports with one invalid", + markdown: "{{#runtime-import ./shared/valid.md}}\n{{#runtime-import ./shared/invalid.md}}", + expectError: true, + errorText: "secrets.MY_TOKEN", + }, + { + name: "non-existent file (should skip)", + markdown: "{{#runtime-import ./shared/nonexistent.md}}", + expectError: false, // Should skip validation for non-existent files + }, + { + name: "URL import (should skip)", + markdown: "{{#runtime-import https://example.com/remote.md}}", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRuntimeImportFiles(tt.markdown, tmpDir) + + if tt.expectError { + require.Error(t, err, "Expected an error") + if tt.errorText != "" { + assert.Contains(t, err.Error(), tt.errorText, "Error should contain expected text") + } + } else { + assert.NoError(t, err, "Expected no error") + } + }) + } +} + +// TestValidateRuntimeImportFiles_PathNormalization tests path normalization +func TestValidateRuntimeImportFiles_PathNormalization(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + githubDir := filepath.Join(tmpDir, ".github") + sharedDir := filepath.Join(githubDir, "shared") + require.NoError(t, os.MkdirAll(sharedDir, 0755)) + + // Create a valid test file + validFile := filepath.Join(sharedDir, "test.md") + validContent := "# Test\n\nActor: ${{ github.actor }}" + require.NoError(t, os.WriteFile(validFile, []byte(validContent), 0644)) + + tests := []struct { + name string + markdown string + expectError bool + }{ + { + name: "path with ./", + markdown: "{{#runtime-import ./shared/test.md}}", + expectError: false, + }, + { + name: "path with .github/", + markdown: "{{#runtime-import .github/shared/test.md}}", + expectError: false, + }, + { + name: "path without prefix", + markdown: "{{#runtime-import shared/test.md}}", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRuntimeImportFiles(tt.markdown, tmpDir) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestCompilerIntegration_RuntimeImportValidation tests the compiler integration +func TestCompilerIntegration_RuntimeImportValidation(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + githubDir := filepath.Join(tmpDir, ".github") + workflowsDir := filepath.Join(githubDir, "workflows") + sharedDir := filepath.Join(githubDir, "shared") + require.NoError(t, os.MkdirAll(workflowsDir, 0755)) + require.NoError(t, os.MkdirAll(sharedDir, 0755)) + + // Create a shared file with invalid expression + sharedFile := filepath.Join(sharedDir, "instructions.md") + sharedContent := `# Shared Instructions + +Use this token: ${{ secrets.GITHUB_TOKEN }} +` + require.NoError(t, os.WriteFile(sharedFile, []byte(sharedContent), 0644)) + + // Create a workflow file that imports the shared file + workflowFile := filepath.Join(workflowsDir, "test-workflow.md") + workflowContent := `--- +on: + issues: + types: [opened] +engine: copilot +--- + +# Test Workflow + +{{#runtime-import ./shared/instructions.md}} + +Please process the issue. +` + require.NoError(t, os.WriteFile(workflowFile, []byte(workflowContent), 0644)) + + // Create compiler and attempt to compile + compiler := NewCompiler(false, "", "test") + + err := compiler.CompileWorkflow(workflowFile) + + // Should fail due to invalid expression in runtime-import file + require.Error(t, err, "Compilation should fail due to invalid expression in runtime-import file") + assert.Contains(t, err.Error(), "runtime-import files contain expression errors", "Error should mention runtime-import files") + assert.Contains(t, err.Error(), "secrets.GITHUB_TOKEN", "Error should mention the specific invalid expression") +} + +// TestCompilerIntegration_RuntimeImportValidation_Valid tests successful compilation +func TestCompilerIntegration_RuntimeImportValidation_Valid(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + githubDir := filepath.Join(tmpDir, ".github") + workflowsDir := filepath.Join(githubDir, "workflows") + sharedDir := filepath.Join(githubDir, "shared") + require.NoError(t, os.MkdirAll(workflowsDir, 0755)) + require.NoError(t, os.MkdirAll(sharedDir, 0755)) + + // Create a shared file with valid expressions + sharedFile := filepath.Join(sharedDir, "instructions.md") + sharedContent := `# Shared Instructions + +Actor: ${{ github.actor }} +Repository: ${{ github.repository }} +Issue: ${{ github.event.issue.number }} +` + require.NoError(t, os.WriteFile(sharedFile, []byte(sharedContent), 0644)) + + // Create a workflow file that imports the shared file + workflowFile := filepath.Join(workflowsDir, "test-workflow.md") + workflowContent := `--- +on: + issues: + types: [opened] +engine: copilot +--- + +# Test Workflow + +{{#runtime-import ./shared/instructions.md}} + +Please process the issue. +` + require.NoError(t, os.WriteFile(workflowFile, []byte(workflowContent), 0644)) + + // Create compiler and compile + compiler := NewCompiler(false, "", "test") + + err := compiler.CompileWorkflow(workflowFile) + + // Should succeed - all expressions are valid + require.NoError(t, err, "Compilation should succeed with valid expressions in runtime-import file") + + // Clean up lock file if it was created + if err == nil { + lockFile := strings.Replace(workflowFile, ".md", ".lock.yml", 1) + os.Remove(lockFile) + } +}