- Core library with SVG and PNG generation - CLI tool with generate and batch commands - Cross-platform path handling for Windows compatibility - Comprehensive test suite with integration tests
181 lines
6.4 KiB
Go
181 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/ungluedlabs/go-jdenticon/jdenticon"
|
|
)
|
|
|
|
// 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.")
|
|
}
|