package main import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "github.com/spf13/cobra" "gitea.dockr.co/kev/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 ", 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.") }