Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions cmd/configdiff/compare.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -117,8 +119,9 @@ func compareFiles(oldFile, newFile string) (bool, error) {
}

// Format and output results (unless quiet mode)
var output string
if !quiet {
output, err := cli.FormatOutput(result, cli.OutputOptions{
output, err = cli.FormatOutput(result, cli.OutputOptions{
Format: outputFormat,
NoColor: noColor,
MaxValueLength: maxValueLength,
Expand All @@ -132,8 +135,17 @@ func compareFiles(oldFile, newFile string) (bool, error) {
fmt.Println(output)
}

// Write GitHub Actions outputs if in GHA environment
hasChanges := cli.HasChanges(result)
if githubOutput := os.Getenv("GITHUB_OUTPUT"); githubOutput != "" {
if err := writeGitHubOutputs(githubOutput, hasChanges, output); err != nil {
// Log error but don't fail the command
fmt.Fprintf(os.Stderr, "Warning: Failed to write GitHub Actions outputs: %v\n", err)
}
}

// Return whether changes were found
return cli.HasChanges(result), nil
return hasChanges, nil
}

// compareDirectories recursively compares two directories.
Expand Down Expand Up @@ -255,3 +267,32 @@ func fileExists(path string) bool {
}
return !info.IsDir()
}

// writeGitHubOutputs writes GitHub Actions outputs to the GITHUB_OUTPUT file
func writeGitHubOutputs(outputFile string, hasChanges bool, diffOutput string) error {
f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()

// Write has-changes output
if _, err := fmt.Fprintf(f, "has-changes=%v\n", hasChanges); err != nil {
return err
}

// Generate random delimiter to prevent injection attacks
// See: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#multiline-strings
delimiterBytes := make([]byte, 16)
if _, err := rand.Read(delimiterBytes); err != nil {
return fmt.Errorf("failed to generate random delimiter: %w", err)
}
delimiter := "ghadelimiter_" + hex.EncodeToString(delimiterBytes)

// Write diff-output using heredoc format with random delimiter
if _, err := fmt.Fprintf(f, "diff-output<<%s\n%s\n%s\n", delimiter, diffOutput, delimiter); err != nil {
return err
}

return nil
}
158 changes: 158 additions & 0 deletions cmd/configdiff/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,23 @@ func findSubstring(s, substr string) bool {
return false
}

func splitLines(s string) []string {
var lines []string
var line string
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, line)
line = ""
} else {
line += string(s[i])
}
}
if line != "" {
lines = append(lines, line)
}
return lines
}

func TestCompareFilesReturnValue(t *testing.T) {
tmpDir := t.TempDir()

Expand Down Expand Up @@ -415,3 +432,144 @@ func TestDirectoryComparisonDoesNotExitEarly(t *testing.T) {
// If we get here, the function completed successfully without os.Exit
// The os.Exit would happen in the caller (compare function), not in compareDirectories
}

func TestWriteGitHubOutputs(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "github_output.txt")

tests := []struct {
name string
hasChanges bool
diffOutput string
wantErr bool
}{
{
name: "with changes",
hasChanges: true,
diffOutput: "path: /config/value\nold: 1\nnew: 2",
wantErr: false,
},
{
name: "no changes",
hasChanges: false,
diffOutput: "",
wantErr: false,
},
{
name: "multiline output",
hasChanges: true,
diffOutput: "Changes:\n Modified: /config/value\n Added: /config/newkey\n Removed: /config/oldkey",
wantErr: false,
},
{
name: "output containing EOF (injection test)",
hasChanges: true,
diffOutput: "Some text\nEOF\ninjected-output=malicious\nMore text",
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Remove output file between tests
os.Remove(outputFile)

err := writeGitHubOutputs(outputFile, tt.hasChanges, tt.diffOutput)
if (err != nil) != tt.wantErr {
t.Errorf("writeGitHubOutputs() error = %v, wantErr %v", err, tt.wantErr)
return
}

if tt.wantErr {
return
}

// Read the output file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}

output := string(content)

// Verify has-changes output
expectedHasChanges := fmt.Sprintf("has-changes=%v\n", tt.hasChanges)
if !contains(output, expectedHasChanges) {
t.Errorf("Output missing expected has-changes line: %q", expectedHasChanges)
}

// Verify diff-output heredoc format with random delimiter
if !contains(output, "diff-output<<ghadelimiter_") {
t.Error("Output missing diff-output heredoc start with random delimiter")
}

// Extract the delimiter and verify it's used correctly
lines := splitLines(output)
var delimiter string
var diffStartIdx int
for i, line := range lines {
if len(line) > len("diff-output<<") && line[:13] == "diff-output<<" {
delimiter = line[13:]
diffStartIdx = i + 1
break
}
}

if delimiter == "" {
t.Error("Failed to extract delimiter from output")
} else {
// Verify delimiter ends the heredoc
found := false
for i := diffStartIdx; i < len(lines); i++ {
if lines[i] == delimiter {
found = true
break
}
}
if !found {
t.Errorf("Delimiter %q not found at end of heredoc", delimiter)
}
}

// Verify diff content is present
if tt.diffOutput != "" && !contains(output, tt.diffOutput) {
t.Errorf("Output missing expected diff content: %q", tt.diffOutput)
}
})
}
}

func TestWriteGitHubOutputsAppend(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "github_output.txt")

// Write initial content
initialContent := "existing-output=test\n"
if err := os.WriteFile(outputFile, []byte(initialContent), 0644); err != nil {
t.Fatalf("Failed to write initial content: %v", err)
}

// Append GitHub outputs
err := writeGitHubOutputs(outputFile, true, "diff content")
if err != nil {
t.Fatalf("writeGitHubOutputs() error = %v", err)
}

// Read file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}

output := string(content)

// Verify initial content is preserved
if !contains(output, initialContent) {
t.Error("Initial content was not preserved")
}

// Verify new content was appended
if !contains(output, "has-changes=true") {
t.Error("New content was not appended")
}
}
Loading
Loading