Files
go-jdenticon/cmd/jdenticon/generate.go
Kevin McIntyre 70b2d55613
All checks were successful
CI / Test (push) Successful in 57s
CI / Lint (push) Successful in 14s
CI / Benchmark (push) Successful in 3m27s
style: fix gofmt formatting in cmd and internal packages
2026-02-10 14:00:27 -05:00

181 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"gitea.dockr.co/kev/go-jdenticon/jdenticon"
"github.com/spf13/cobra"
)
// isRootPath checks if the given path is a filesystem root.
// On Unix, this is "/". On Windows, this is a drive root like "C:\" or a UNC root.
func isRootPath(path string) bool {
if path == "/" {
return true
}
// On Windows, check for drive roots (e.g., "C:\") or when Dir(path) == path
if runtime.GOOS == "windows" {
// filepath.VolumeName returns "C:" for "C:\", empty for Unix paths
vol := filepath.VolumeName(path)
if vol != "" && (path == vol || path == vol+string(os.PathSeparator)) {
return true
}
}
// Generic check: if going up doesn't change the path, we're at root
return filepath.Dir(path) == path
}
// validateAndResolveOutputPath ensures the target path is within or is the base directory.
// It returns the absolute, cleaned, and validated path, or an error.
// The baseDir is typically os.Getwd() for a CLI tool, representing the trusted root.
func validateAndResolveOutputPath(baseDir, outputPath string) (string, error) {
// 1. Get absolute and cleaned path of the base directory.
// This ensures we have a canonical, absolute reference for our allowed root.
absBaseDir, err := filepath.Abs(baseDir)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for base directory %q: %w", baseDir, err)
}
absBaseDir = filepath.Clean(absBaseDir)
// 2. Get absolute and cleaned path of the user-provided output file.
// This resolves all relative components (., ..) and converts to an absolute path.
absOutputPath, err := filepath.Abs(outputPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for output file %q: %w", outputPath, err)
}
absOutputPath = filepath.Clean(absOutputPath)
// 3. Resolve any symlinks to ensure we're comparing canonical paths.
// This handles cases like macOS where /var is symlinked to /private/var.
absBaseDir, err = filepath.EvalSymlinks(absBaseDir)
if err != nil {
return "", fmt.Errorf("failed to resolve symlinks for base directory %q: %w", absBaseDir, err)
}
originalOutputPath := absOutputPath
resolvedDir, err := filepath.EvalSymlinks(filepath.Dir(absOutputPath))
if err != nil {
// If the directory doesn't exist yet, try to resolve up to the existing parent
parentDir := filepath.Dir(absOutputPath)
for !isRootPath(parentDir) && parentDir != "." {
if resolvedParent, err := filepath.EvalSymlinks(parentDir); err == nil {
absOutputPath = filepath.Join(resolvedParent, filepath.Base(originalOutputPath))
break
}
parentDir = filepath.Dir(parentDir)
}
// If we couldn't resolve any parent, keep the original path
// (absOutputPath already equals originalOutputPath, nothing to do)
} else {
absOutputPath = filepath.Join(resolvedDir, filepath.Base(originalOutputPath))
}
absOutputPath = filepath.Clean(absOutputPath)
// 4. Crucial Security Check: Ensure absOutputPath is within absBaseDir.
// a. If absOutputPath is identical to absBaseDir (e.g., user specified "."), it's allowed.
// b. Otherwise, absOutputPath must start with absBaseDir followed by a path separator.
// This prevents cases where absOutputPath is merely a prefix of absBaseDir
// (e.g., /home/user/project_other vs /home/user/project).
if absOutputPath != absBaseDir && !strings.HasPrefix(absOutputPath, absBaseDir+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid output path: %q (resolved to %q) is outside allowed directory %q",
outputPath, absOutputPath, absBaseDir)
}
return absOutputPath, nil
}
// generateCmd represents the generate command
var generateCmd = &cobra.Command{
Use: "generate <value>",
Short: "Generate a single identicon",
Long: `Generate a single identicon based on the provided value (e.g., a hash, username, or email).
The identicon will be written to stdout by default, or to a file if --output is specified.
All configuration options from the root command apply.
Examples:
jdenticon generate "user@example.com"
jdenticon generate "user@example.com" --size 128 --format png --output avatar.png
jdenticon generate "github-username" --color-saturation 0.7 --padding 0.1`,
Args: cobra.ExactArgs(1), // Validate argument count at the Cobra level
RunE: func(cmd *cobra.Command, args []string) error {
// Get the input value
value := args[0]
// Get output file flag
outputFile, _ := cmd.Flags().GetString("output")
// Get format from viper
format, err := getFormatFromViper()
if err != nil {
return err
}
// Populate library config from root persistent flags
config, size, err := populateConfigFromFlags()
if err != nil {
return err
}
// Generate the identicon with custom config
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
if err != nil {
return fmt.Errorf("failed to create generator: %w", err)
}
icon, err := generator.Generate(context.Background(), value, size)
if err != nil {
return fmt.Errorf("failed to generate identicon: %w", err)
}
// Generate output based on format
result, err := renderIcon(icon, format)
if err != nil {
return err
}
// Output to file or stdout
if outputFile != "" {
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
baseDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
// Validate and resolve the user-provided output path.
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
if err != nil {
// This is a security-related error, explicitly state it.
return fmt.Errorf("security error: %w", err)
}
// Now use the safe and validated path for writing.
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
return fmt.Errorf("failed to write output file %q: %w", safeOutputPath, err)
}
fmt.Fprintf(os.Stderr, "Identicon saved to %s\n", safeOutputPath)
} else {
// Write to stdout for piping
if _, err := cmd.OutOrStdout().Write(result); err != nil {
return fmt.Errorf("failed to write to stdout: %w", err)
}
}
return nil
},
}
func init() {
rootCmd.AddCommand(generateCmd)
// Define local flags specific to 'generate'
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
}