Initial release: Go Jdenticon library v0.1.0
- 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
This commit is contained in:
180
cmd/jdenticon/generate.go
Normal file
180
cmd/jdenticon/generate.go
Normal file
@@ -0,0 +1,180 @@
|
||||
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.")
|
||||
}
|
||||
Reference in New Issue
Block a user