Skip to content

Commit c2cc096

Browse files
committed
Luhn number utilities
1 parent ad6eb3d commit c2cc096

File tree

12 files changed

+665
-0
lines changed

12 files changed

+665
-0
lines changed

.goreleaser.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,40 @@ builds:
143143
- -X main.COMMIT={{.ShortCommit}}
144144
- -X main.BUILDER=goreleaser
145145

146+
- id: luhngen
147+
main: ./cmd/luhngen/luhngen.go
148+
binary: luhngen
149+
env:
150+
- CGO_ENABLED=0
151+
goos:
152+
- linux
153+
- windows
154+
- darwin
155+
ldflags:
156+
- -s
157+
- -w
158+
- -X main.VERSION={{.Version}}
159+
- -X main.LASTMOD={{.CommitDate}}
160+
- -X main.COMMIT={{.ShortCommit}}
161+
- -X main.BUILDER=goreleaser
162+
163+
- id: luhncheck
164+
main: ./cmd/luhncheck/luhncheck.go
165+
binary: luhncheck
166+
env:
167+
- CGO_ENABLED=0
168+
goos:
169+
- linux
170+
- windows
171+
- darwin
172+
ldflags:
173+
- -s
174+
- -w
175+
- -X main.VERSION={{.Version}}
176+
- -X main.LASTMOD={{.CommitDate}}
177+
- -X main.COMMIT={{.ShortCommit}}
178+
- -X main.BUILDER=goreleaser
179+
146180
- id: unhexdump
147181
main: ./cmd/unhexdump/unhexdump.go
148182
binary: unhexdump
@@ -238,6 +272,8 @@ homebrew_casks:
238272
- hexdumpc
239273
- hosty
240274
- jsonfmt
275+
- luhncheck
276+
- luhngen
241277
- unhexdump
242278
- unicount
243279
- uniwhat
@@ -266,6 +302,8 @@ homebrew_casks:
266302
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/hexdumpc"]
267303
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/hosty"]
268304
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/jsonfmt"]
305+
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/luhncheck"]
306+
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/luhngen"]
269307
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/unhexdump"]
270308
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/unicount"]
271309
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/uniwhat"]

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Or download from [Releases](https://github.com/FileFormatInfo/fftools/releases)
2121
- [hexdumpc](cmd/hexdumpc/README.md): generate canonical hexdump (`hexdump -C`) output in case you don't have [`hexdump`](https://man7.org/linux/man-pages/man1/hexdump.1.html) installed
2222
- [hosty](cmd/hosty/README.md): manipulate hostnames
2323
- [jsonfmt](cmd/jsonfmt/README.md): format JSON (expanded, canonical, line, fractured)
24+
- [luhncheck](cmd/luhncheck/README.md): check numbers against the Luhn algorithm
25+
- [luhngen](cmd/luhngen/README.md): generate numbers that pass Luhn checks
2426
- [unhexdump](cmd/unhexdump/README.md): convert `hexdump -c` output back into binary
2527
- [unicount](cmd/unicount/README.md): count Unicode codepoints in a file
2628
- [uniwhat](cmd/uniwhat/README.md): print the names of each Unicode character in a file

cmd/luhncheck/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# luhncheck
2+
3+
Checks if a number passes the [Luhn algorithm](https://en.wikipedia.org/wiki/Luhn_algorithm) check
4+
5+
## Options
6+
7+
* `--no-error-level`: don't return an errorlevel if it doesn't pass (note: you can still get an errorlevel if the number is missing or cannot be parsed)
8+
* `--verbose`: if it does not pass, print a message with the last digit fixed to pass
9+
* `--quiet`: do not print `PASS`/`FAIL`

cmd/luhncheck/luhncheck.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package main
2+
3+
import (
4+
_ "embed"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/spf13/pflag"
10+
)
11+
12+
var (
13+
BUILDER = "unknown"
14+
COMMIT = "(local)"
15+
LASTMOD = "(local)"
16+
VERSION = "internal"
17+
)
18+
19+
//go:embed README.md
20+
var helpText string
21+
22+
func allDigits(s string) bool {
23+
if s == "" {
24+
return false
25+
}
26+
for _, r := range s {
27+
if r < '0' || r > '9' {
28+
return false
29+
}
30+
}
31+
return true
32+
}
33+
34+
func luhnCheckDigit(withoutCheckDigit string) (byte, error) {
35+
if withoutCheckDigit == "" {
36+
return 0, fmt.Errorf("input is empty")
37+
}
38+
if !allDigits(withoutCheckDigit) {
39+
return 0, fmt.Errorf("input contains non-digit characters")
40+
}
41+
42+
sum := 0
43+
double := true
44+
for i := len(withoutCheckDigit) - 1; i >= 0; i-- {
45+
d := int(withoutCheckDigit[i] - '0')
46+
if double {
47+
d *= 2
48+
if d > 9 {
49+
d -= 9
50+
}
51+
}
52+
sum += d
53+
double = !double
54+
}
55+
56+
check := (10 - (sum % 10)) % 10
57+
return byte('0' + check), nil
58+
}
59+
60+
func luhnValid(number string) bool {
61+
if len(number) < 2 || !allDigits(number) {
62+
return false
63+
}
64+
cd, err := luhnCheckDigit(number[:len(number)-1])
65+
if err != nil {
66+
return false
67+
}
68+
return number[len(number)-1] == cd
69+
}
70+
71+
func correctedLuhn(number string) (string, error) {
72+
if len(number) < 2 {
73+
return "", fmt.Errorf("number must have at least 2 digits")
74+
}
75+
if !allDigits(number) {
76+
return "", fmt.Errorf("number must contain only digits")
77+
}
78+
cd, err := luhnCheckDigit(number[:len(number)-1])
79+
if err != nil {
80+
return "", err
81+
}
82+
return number[:len(number)-1] + string(cd), nil
83+
}
84+
85+
func main() {
86+
var noErrorLevel = pflag.Bool("no-error-level", false, "Do not return non-zero exit code when Luhn check fails")
87+
var verbose = pflag.Bool("verbose", false, "If check fails, print corrected number with fixed last digit")
88+
var quiet = pflag.Bool("quiet", false, "Do not print PASS/FAIL")
89+
90+
var help = pflag.BoolP("help", "h", false, "Show help message")
91+
var version = pflag.Bool("version", false, "Print version information")
92+
93+
pflag.Parse()
94+
95+
if *version {
96+
fmt.Fprintf(os.Stdout, "luhncheck version %s (built by %s on %s, commit %s)\n", VERSION, BUILDER, LASTMOD, COMMIT)
97+
return
98+
}
99+
100+
if *help {
101+
fmt.Printf("Usage: luhncheck [options] <number>\n\n")
102+
fmt.Printf("Options:\n")
103+
pflag.PrintDefaults()
104+
fmt.Printf("%s\n", helpText)
105+
return
106+
}
107+
108+
args := pflag.Args()
109+
if len(args) == 0 {
110+
fmt.Fprintf(os.Stderr, "ERROR: missing number argument\n")
111+
os.Exit(1)
112+
}
113+
if len(args) > 1 {
114+
fmt.Fprintf(os.Stderr, "WARNING: ignoring extra arguments (count=%d)\n", len(args)-1)
115+
}
116+
117+
number := strings.TrimSpace(args[0])
118+
if len(number) < 2 {
119+
fmt.Fprintf(os.Stderr, "ERROR: number must have at least 2 digits\n")
120+
os.Exit(1)
121+
}
122+
if !allDigits(number) {
123+
fmt.Fprintf(os.Stderr, "ERROR: number must contain only digits\n")
124+
os.Exit(1)
125+
}
126+
127+
valid := luhnValid(number)
128+
if !*quiet {
129+
if valid {
130+
fmt.Fprint(os.Stdout, "PASS")
131+
} else {
132+
fmt.Fprint(os.Stdout, "FAIL")
133+
}
134+
}
135+
136+
if !valid && *verbose {
137+
fixed, err := correctedLuhn(number)
138+
if err == nil {
139+
if !*quiet {
140+
fmt.Fprintln(os.Stdout)
141+
}
142+
fmt.Fprintf(os.Stdout, "Fixed: %s", fixed)
143+
}
144+
}
145+
146+
if valid || *noErrorLevel {
147+
return
148+
}
149+
os.Exit(1)
150+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/rogpeppe/go-internal/testscript"
8+
)
9+
10+
func TestMain(m *testing.M) {
11+
exitVal := testscript.RunMain(m, map[string]func() int{
12+
"luhncheck": func() int {
13+
main()
14+
return 0
15+
},
16+
})
17+
os.Exit(exitVal)
18+
}
19+
20+
func TestLuhncheckScript(t *testing.T) {
21+
testscript.Run(t, testscript.Params{
22+
Files: []string{"../../testdata/luhncheck.txtar"},
23+
})
24+
}

cmd/luhncheck/luhncheck_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestLuhnCheckDigit(t *testing.T) {
6+
cd, err := luhnCheckDigit("7992739871")
7+
if err != nil {
8+
t.Fatalf("luhnCheckDigit error: %v", err)
9+
}
10+
if cd != '3' {
11+
t.Fatalf("luhnCheckDigit = %q, want %q", cd, '3')
12+
}
13+
}
14+
15+
func TestLuhnValid(t *testing.T) {
16+
if !luhnValid("79927398713") {
17+
t.Fatalf("expected known valid number to pass")
18+
}
19+
if luhnValid("79927398714") {
20+
t.Fatalf("expected known invalid number to fail")
21+
}
22+
}
23+
24+
func TestCorrectedLuhn(t *testing.T) {
25+
fixed, err := correctedLuhn("79927398714")
26+
if err != nil {
27+
t.Fatalf("correctedLuhn error: %v", err)
28+
}
29+
if fixed != "79927398713" {
30+
t.Fatalf("correctedLuhn = %s, want 79927398713", fixed)
31+
}
32+
}
33+
34+
func TestCorrectedLuhnErrors(t *testing.T) {
35+
if _, err := correctedLuhn("1"); err == nil {
36+
t.Fatalf("expected short input error")
37+
}
38+
if _, err := correctedLuhn("12a3"); err == nil {
39+
t.Fatalf("expected non-digit input error")
40+
}
41+
}

cmd/luhngen/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# luhngen
2+
3+
Generate numbers that pass the [Luhn algorithm](https://en.wikipedia.org/wiki/Luhn_algorithm) check.
4+
5+
## Options
6+
7+
* `--length`: number of digits
8+
* `--prefix`: first few digits (if you need a specific BIN or card type)
9+
* `--cardtype`: if you want a specific card type. Options are `V` (Visa), `M` (Mastercard), `D` (Discover) and `A` (American Express). Will set the length and prefix.
10+
* `--seed`: seed for deterministic pseudo-random output
11+
* `--trailing-newline`: if a trailing newline should be emitted

0 commit comments

Comments
 (0)