summaryrefslogtreecommitdiffstats
path: root/internal/identity/cli.go
blob: 85408c8ed85a4376a56f4de081efeea7187ecf2d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// Package identity integrates the existing identicon-cli program without
// copying its GUI or reimplementing its rendering algorithm.
package identity

import (
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"image"
	"image/png"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

var cliCandidates = []string{
	"/home/gabriel1/Projects/identicons/identicons-cli",
	"/home/gabriel1/Workspace/Projects/identicons/identicons",
	"/usr/local/bin/identicon-cli",
	"/usr/bin/identicon-cli",
	"/SDCard/identicon-cli",
	"/Shared/identicon-cli",
}

// FindCLI returns the configured or locally installed identicon-cli binary.
func FindCLI() (string, error) {
	if configured := strings.TrimSpace(os.Getenv("AEGIS_IDENTICON_CLI")); configured != "" {
		if isExecutable(configured) {
			return configured, nil
		}
		return "", fmt.Errorf("AEGIS_IDENTICON_CLI is not executable: %s", configured)
	}
	for _, candidate := range cliCandidates {
		if isExecutable(candidate) {
			return candidate, nil
		}
	}
	return "", errors.New("identicon-cli executable not found")
}

func isExecutable(path string) bool {
	info, err := os.Stat(path)
	return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0
}

// GenerateFace delegates to identicon-cli with its existing 48px rendering
// and returns the base64 PNG payload used in a Face header.
func GenerateFace(seed string) (string, error) {
	if strings.TrimSpace(seed) == "" {
		return "", errors.New("identicon seed is required")
	}
	return generateFace(seed, 48)
}

func generateFace(seed string, size int) (string, error) {
	if strings.TrimSpace(seed) == "" {
		return "", errors.New("identicon seed is required")
	}
	if size <= 0 {
		return "", errors.New("identicon size must be positive")
	}
	executable, err := FindCLI()
	if err != nil {
		return "", err
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	command := exec.CommandContext(ctx, executable, "-input", seed, "-format", "base64", "-size", fmt.Sprintf("%d", size), "-transparent")
	var stdout, stderr bytes.Buffer
	command.Stdout = &stdout
	command.Stderr = &stderr
	if err := command.Run(); err != nil {
		return "", fmt.Errorf("run identicon-cli: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	value := strings.TrimSpace(stdout.String())
	if _, err := pngImageFromBase64(value, size); err != nil {
		return "", fmt.Errorf("identicon-cli returned invalid Face data: %w", err)
	}
	return value, nil
}

func decodeFacePNG(value string) ([]byte, error) {
	return pngBytes(value, 48)
}

func pngBytes(value string, size int) ([]byte, error) {
	data, err := base64.StdEncoding.DecodeString(value)
	if err != nil {
		return nil, fmt.Errorf("decode base64: %w", err)
	}
	if _, err := pngImage(data, size); err != nil {
		return nil, err
	}
	return data, nil
}

func pngImageFromBase64(value string, size int) (image.Image, error) {
	data, err := pngBytes(value, size)
	if err != nil {
		return nil, err
	}
	return pngImage(data, size)
}

func pngImage(data []byte, size int) (image.Image, error) {
	img, err := png.Decode(bytes.NewReader(data))
	if err != nil {
		return nil, fmt.Errorf("decode PNG: %w", err)
	}
	if img.Bounds().Dx() != size || img.Bounds().Dy() != size {
		return nil, fmt.Errorf("PNG dimensions are %dx%d, want %dx%d", img.Bounds().Dx(), img.Bounds().Dy(), size, size)
	}
	return img, nil
}

// FormatFaceHeader folds the Face value in the same 70/75-byte pattern used
// by the existing identicon-cli output files.
func FormatFaceHeader(value string) string {
	if len(value) <= 70 {
		return "Face: " + value
	}
	var out strings.Builder
	out.WriteString("Face: ")
	out.WriteString(value[:70])
	for position := 70; position < len(value); position += 75 {
		end := position + 75
		if end > len(value) {
			end = len(value)
		}
		out.WriteString("\r\n ")
		out.WriteString(value[position:end])
	}
	return out.String()
}

// CLIPathForDisplay returns a stable path label without exposing environment
// secrets or invoking a shell.
func CLIPathForDisplay(path string) string {
	if path == "" {
		return ""
	}
	return filepath.Clean(path)
}