forked from sneak/util
Compare commits
19 Commits
proposal-c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0183d1a782 | |||
| 2d714eea9f | |||
| 1a6d1b429b | |||
| c656ce964e | |||
| 74b448851e | |||
| 64965b11ed | |||
| 5bf829aefb | |||
| c407af0d21 | |||
| d70ad443e3 | |||
| 96e904ffd1 | |||
| 62f769ae9b | |||
| 9708301eba | |||
| 9d3d35b3d7 | |||
| 4937903190 | |||
| 500768ad32 | |||
| 64eaceb0ef | |||
| daaea4e10f | |||
| 6807a76f24 | |||
| 7fb688772e |
56
atomicwrite.go
Normal file
56
atomicwrite.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AtomicWriteFile writes data to path without ever leaving a half-written file
|
||||||
|
// behind. It writes to a temporary file in the same directory, flushes it to
|
||||||
|
// disk, gives it the mode in perm and renames it over path, so anything reading
|
||||||
|
// path sees either the previous contents or the complete new contents and
|
||||||
|
// never something in between. If any step fails, the temporary file is removed
|
||||||
|
// and path is left as it was.
|
||||||
|
//
|
||||||
|
// The temporary file has to live in the same directory as path, because a
|
||||||
|
// rename across two filesystems is not possible and would not be instant if it
|
||||||
|
// were.
|
||||||
|
//
|
||||||
|
// Two details differ from os.WriteFile. The mode is applied after the file is
|
||||||
|
// created, so perm is what the finished file ends up with rather than perm with
|
||||||
|
// the process umask taken out of it. And the directory holding the file is not
|
||||||
|
// flushed, so a machine that loses power immediately after this returns may
|
||||||
|
// come back with the rename undone, even though the data itself was written.
|
||||||
|
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||||
|
directory := filepath.Dir(path)
|
||||||
|
|
||||||
|
temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
|
||||||
|
// Does nothing once the rename below has succeeded, because by then the
|
||||||
|
// temporary name no longer refers to anything.
|
||||||
|
defer os.Remove(temporaryPath)
|
||||||
|
|
||||||
|
if _, err := temporary.Write(data); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
temporary.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(temporaryPath, perm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(temporaryPath, path)
|
||||||
|
}
|
||||||
110
atomicwrite_test.go
Normal file
110
atomicwrite_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAtomicWriteFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existing string
|
||||||
|
data []byte
|
||||||
|
perm os.FileMode
|
||||||
|
}{
|
||||||
|
{"a new file", "", []byte("hello"), 0644},
|
||||||
|
{"replacing a shorter file", "old", []byte("a much longer set of contents"), 0644},
|
||||||
|
{"replacing a longer file", "a much longer set of contents", []byte("new"), 0644},
|
||||||
|
{"an empty payload", "something", []byte{}, 0644},
|
||||||
|
{"a nil payload", "something", nil, 0644},
|
||||||
|
{"a private mode", "", []byte("secret"), 0600},
|
||||||
|
{"an executable mode", "", []byte("#!/bin/sh\n"), 0755},
|
||||||
|
{"binary contents", "", []byte{0x00, 0xff, 0x10, 0x00}, 0644},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "target")
|
||||||
|
|
||||||
|
if test.existing != "" {
|
||||||
|
if err := os.WriteFile(path, []byte(test.existing), 0666); err != nil {
|
||||||
|
t.Fatalf("could not write the file to be replaced: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AtomicWriteFile(path, test.data, test.perm); err != nil {
|
||||||
|
t.Fatalf("did not expect an error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not read the file back: %v", err)
|
||||||
|
}
|
||||||
|
if string(written) != string(test.data) {
|
||||||
|
t.Errorf("expected contents %q got %q", string(test.data), string(written))
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not look at the file: %v", err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != test.perm {
|
||||||
|
t.Errorf("expected mode %v got %v", test.perm, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not list the directory: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
t.Errorf("expected only the target file to be left, got %v", names)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWriteFileReportsAMissingDirectory(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "not-there", "target")
|
||||||
|
|
||||||
|
if err := AtomicWriteFile(path, []byte("hello"), 0644); err == nil {
|
||||||
|
t.Errorf("expected an error for a directory that does not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWriteFileLeavesTheOldFileAloneOnFailure(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
path := filepath.Join(directory, "target")
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte("original"), 0644); err != nil {
|
||||||
|
t.Fatalf("could not write the file to be replaced: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory that cannot be written to means the temporary file cannot
|
||||||
|
// be created, so the write has to fail before anything is replaced.
|
||||||
|
if err := os.Chmod(directory, 0500); err != nil {
|
||||||
|
t.Fatalf("could not change the directory mode: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Chmod(directory, 0700)
|
||||||
|
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("running as root, which ignores the directory mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AtomicWriteFile(path, []byte("replacement"), 0644); err == nil {
|
||||||
|
t.Fatalf("expected an error for a directory that cannot be written to")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not read the file back: %v", err)
|
||||||
|
}
|
||||||
|
if string(existing) != "original" {
|
||||||
|
t.Errorf("expected the original contents to survive, got %q", string(existing))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
chunk.go
Normal file
26
chunk.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
// ChunkStrings splits input into consecutive slices of at most size elements.
|
||||||
|
// The last slice holds whatever is left over and may be shorter than the
|
||||||
|
// others. Each returned slice is a copy, so appending to one of them cannot
|
||||||
|
// disturb another. A size below one, or an empty input, returns nil.
|
||||||
|
func ChunkStrings(input []string, size int) [][]string {
|
||||||
|
if size < 1 || len(input) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([][]string, 0, (len(input)+size-1)/size)
|
||||||
|
|
||||||
|
for start := 0; start < len(input); start += size {
|
||||||
|
end := start + size
|
||||||
|
if end > len(input) {
|
||||||
|
end = len(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := make([]string, end-start)
|
||||||
|
copy(chunk, input[start:end])
|
||||||
|
out = append(out, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
43
chunk_test.go
Normal file
43
chunk_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChunkStrings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
size int
|
||||||
|
expected [][]string
|
||||||
|
}{
|
||||||
|
{"exact multiple", []string{"a", "b", "c", "d"}, 2, [][]string{{"a", "b"}, {"c", "d"}}},
|
||||||
|
{"with a remainder", []string{"a", "b", "c"}, 2, [][]string{{"a", "b"}, {"c"}}},
|
||||||
|
{"size larger than input", []string{"a", "b"}, 5, [][]string{{"a", "b"}}},
|
||||||
|
{"size of one", []string{"a", "b"}, 1, [][]string{{"a"}, {"b"}}},
|
||||||
|
{"size of zero", []string{"a", "b"}, 0, nil},
|
||||||
|
{"negative size", []string{"a", "b"}, -1, nil},
|
||||||
|
{"empty input", []string{}, 2, nil},
|
||||||
|
{"nil input", nil, 2, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := ChunkStrings(test.input, test.size)
|
||||||
|
if !reflect.DeepEqual(got, test.expected) {
|
||||||
|
t.Errorf("expected %#v got %#v", test.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkStringsReturnsCopies(t *testing.T) {
|
||||||
|
input := []string{"a", "b", "c", "d"}
|
||||||
|
chunks := ChunkStrings(input, 2)
|
||||||
|
chunks[0][0] = "changed"
|
||||||
|
|
||||||
|
if input[0] != "a" {
|
||||||
|
t.Errorf("expected the input to be unchanged, got %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
dedupe.go
Normal file
23
dedupe.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
// DedupeStrings returns a new slice holding each value of input once, in the
|
||||||
|
// order in which each value first appears. The input slice is not modified.
|
||||||
|
// A nil input returns nil.
|
||||||
|
func DedupeStrings(input []string) []string {
|
||||||
|
if input == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(input))
|
||||||
|
out := make([]string, 0, len(input))
|
||||||
|
|
||||||
|
for _, value := range input {
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
40
dedupe_test.go
Normal file
40
dedupe_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDedupeStrings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{"nil stays nil", nil, nil},
|
||||||
|
{"empty slice", []string{}, []string{}},
|
||||||
|
{"nothing repeated", []string{"a", "b", "c"}, []string{"a", "b", "c"}},
|
||||||
|
{"all one value", []string{"a", "a", "a"}, []string{"a"}},
|
||||||
|
{"repeats next to each other", []string{"a", "a", "b", "b"}, []string{"a", "b"}},
|
||||||
|
{"repeats far apart", []string{"a", "b", "a", "c", "b"}, []string{"a", "b", "c"}},
|
||||||
|
{"empty string is a value", []string{"", "a", ""}, []string{"", "a"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := DedupeStrings(test.input)
|
||||||
|
if !reflect.DeepEqual(got, test.expected) {
|
||||||
|
t.Errorf("expected %#v got %#v", test.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDedupeStringsLeavesInputAlone(t *testing.T) {
|
||||||
|
input := []string{"b", "a", "b"}
|
||||||
|
DedupeStrings(input)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(input, []string{"b", "a", "b"}) {
|
||||||
|
t.Errorf("expected the input to be unchanged, got %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
exists.go
Normal file
31
exists.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// FileExists reports whether there is a regular file at path. A directory, a
|
||||||
|
// device node or a socket gives false, and so does anything the process is not
|
||||||
|
// allowed to look at, since from the caller's point of view there is no usable
|
||||||
|
// file there either way.
|
||||||
|
//
|
||||||
|
// Symbolic links are followed, so a link pointing at a regular file gives true
|
||||||
|
// and a link pointing at nothing gives false.
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirExists reports whether there is a directory at path. As with FileExists,
|
||||||
|
// anything that cannot be looked at gives false, and symbolic links are
|
||||||
|
// followed.
|
||||||
|
func DirExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.IsDir()
|
||||||
|
}
|
||||||
57
exists_test.go
Normal file
57
exists_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileExistsAndDirExists(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
file := filepath.Join(root, "a-file")
|
||||||
|
if err := os.WriteFile(file, []byte("contents"), 0644); err != nil {
|
||||||
|
t.Fatalf("could not write the test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
directory := filepath.Join(root, "a-directory")
|
||||||
|
if err := os.Mkdir(directory, 0755); err != nil {
|
||||||
|
t.Fatalf("could not make the test directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkToFile := filepath.Join(root, "link-to-file")
|
||||||
|
if err := os.Symlink(file, linkToFile); err != nil {
|
||||||
|
t.Fatalf("could not make the test link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenLink := filepath.Join(root, "broken-link")
|
||||||
|
if err := os.Symlink(filepath.Join(root, "nothing-here"), brokenLink); err != nil {
|
||||||
|
t.Fatalf("could not make the broken test link: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expectedFile bool
|
||||||
|
expectedIsDir bool
|
||||||
|
}{
|
||||||
|
{"a regular file", file, true, false},
|
||||||
|
{"a directory", directory, false, true},
|
||||||
|
{"a link to a file", linkToFile, true, false},
|
||||||
|
{"a link to nothing", brokenLink, false, false},
|
||||||
|
{"a path that is not there", filepath.Join(root, "missing"), false, false},
|
||||||
|
{"a path whose parent is not there", filepath.Join(root, "missing", "deeper"), false, false},
|
||||||
|
{"the empty path", "", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := FileExists(test.path); got != test.expectedFile {
|
||||||
|
t.Errorf("FileExists: expected %v got %v", test.expectedFile, got)
|
||||||
|
}
|
||||||
|
if got := DirExists(test.path); got != test.expectedIsDir {
|
||||||
|
t.Errorf("DirExists: expected %v got %v", test.expectedIsDir, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
humanbytes.go
Normal file
38
humanbytes.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bytesPerUnit = 1024
|
||||||
|
|
||||||
|
var byteUnits = []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
|
||||||
|
|
||||||
|
// HumanBytes writes a byte count the way a person would read it, using powers
|
||||||
|
// of 1024: "0 B", "1023 B", "1.0 KiB", "1.5 MiB", "16.0 EiB". Counts below 1024
|
||||||
|
// are given as whole bytes, and anything larger gets one digit after the
|
||||||
|
// decimal point.
|
||||||
|
func HumanBytes(bytes uint64) string {
|
||||||
|
if bytes < bytesPerUnit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
value := float64(bytes)
|
||||||
|
unit := -1
|
||||||
|
|
||||||
|
for value >= bytesPerUnit && unit < len(byteUnits)-1 {
|
||||||
|
value /= bytesPerUnit
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounding happens after the unit has been chosen, so a count just short
|
||||||
|
// of the next threshold would otherwise come out as "1024.0 KiB" rather
|
||||||
|
// than "1.0 MiB".
|
||||||
|
if unit < len(byteUnits)-1 && math.Round(value*10)/10 >= bytesPerUnit {
|
||||||
|
value /= bytesPerUnit
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.1f %s", value, byteUnits[unit])
|
||||||
|
}
|
||||||
33
humanbytes_test.go
Normal file
33
humanbytes_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHumanBytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input uint64
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"zero", 0, "0 B"},
|
||||||
|
{"one byte", 1, "1 B"},
|
||||||
|
{"just below a kibibyte", 1023, "1023 B"},
|
||||||
|
{"exactly a kibibyte", 1024, "1.0 KiB"},
|
||||||
|
{"half a kibibyte more", 1536, "1.5 KiB"},
|
||||||
|
{"rounds up into the next unit", 1048575, "1.0 MiB"},
|
||||||
|
{"exactly a mebibyte", 1 << 20, "1.0 MiB"},
|
||||||
|
{"a gibibyte", 1 << 30, "1.0 GiB"},
|
||||||
|
{"a tebibyte", 1 << 40, "1.0 TiB"},
|
||||||
|
{"a pebibyte", 1 << 50, "1.0 PiB"},
|
||||||
|
{"an exbibyte", 1 << 60, "1.0 EiB"},
|
||||||
|
{"the largest count there is", ^uint64(0), "16.0 EiB"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := HumanBytes(test.input)
|
||||||
|
if got != test.expected {
|
||||||
|
t.Errorf("expected %q got %q", test.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
random.go
Normal file
28
random.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RandomHexString returns byteLength random bytes from the operating system's
|
||||||
|
// random source, written as lowercase hexadecimal, so the returned string is
|
||||||
|
// twice as long as byteLength. The bytes come from crypto/rand, which means the
|
||||||
|
// result is fit for session tokens, temporary filenames and anything else a
|
||||||
|
// stranger should not be able to guess.
|
||||||
|
//
|
||||||
|
// An error comes back only for a negative length or if the random source
|
||||||
|
// itself fails, which does not happen on a working system.
|
||||||
|
func RandomHexString(byteLength int) (string, error) {
|
||||||
|
if byteLength < 0 {
|
||||||
|
return "", errors.New("byte length cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, byteLength)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(buffer), nil
|
||||||
|
}
|
||||||
57
random_test.go
Normal file
57
random_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRandomHexStringLength(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
byteLength int
|
||||||
|
expectedLength int
|
||||||
|
}{
|
||||||
|
{"no bytes at all", 0, 0},
|
||||||
|
{"one byte", 1, 2},
|
||||||
|
{"eight bytes", 8, 16},
|
||||||
|
{"sixteen bytes", 16, 32},
|
||||||
|
{"thirty-two bytes", 32, 64},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := RandomHexString(test.byteLength)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("did not expect an error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != test.expectedLength {
|
||||||
|
t.Errorf("expected a string of %d characters, got %d (%q)", test.expectedLength, len(got), got)
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(got); err != nil {
|
||||||
|
t.Errorf("expected valid hexadecimal, got %q: %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomHexStringRejectsNegativeLength(t *testing.T) {
|
||||||
|
got, err := RandomHexString(-1)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected an error for a negative length, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomHexStringDiffersBetweenCalls(t *testing.T) {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
value, err := RandomHexString(16)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("did not expect an error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, repeated := seen[value]; repeated {
|
||||||
|
t.Fatalf("got the same string twice: %q", value)
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
sha256file.go
Normal file
30
sha256file.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SHA256File returns the SHA-256 digest of the contents of the file at path,
|
||||||
|
// written as lowercase hexadecimal, in the same form as the sha256sum command
|
||||||
|
// prints. The file is copied through the hash in chunks rather than read into
|
||||||
|
// memory, so its size does not matter.
|
||||||
|
//
|
||||||
|
// The error is whatever went wrong opening or reading the file, passed along
|
||||||
|
// unchanged so a caller can test it with os.IsNotExist and the like.
|
||||||
|
func SHA256File(path string) (string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
digest := sha256.New()
|
||||||
|
if _, err := io.Copy(digest, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||||
|
}
|
||||||
76
sha256file_test.go
Normal file
76
sha256file_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSHA256File(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contents []byte
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"an empty file",
|
||||||
|
[]byte{},
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a short file",
|
||||||
|
[]byte("hello world\n"),
|
||||||
|
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binary contents",
|
||||||
|
[]byte{0x00, 0x01, 0x02, 0x03},
|
||||||
|
"054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"larger than one read buffer",
|
||||||
|
bytes.Repeat([]byte("a"), 100000),
|
||||||
|
"6d1cf22d7cc09b085dfc25ee1a1f3ae0265804c607bc2074ad253bcc82fd81ee",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "file")
|
||||||
|
if err := os.WriteFile(path, test.contents, 0644); err != nil {
|
||||||
|
t.Fatalf("could not write the test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("did not expect an error, got %v", err)
|
||||||
|
}
|
||||||
|
if got != test.expected {
|
||||||
|
t.Errorf("expected %q got %q", test.expected, got)
|
||||||
|
}
|
||||||
|
if got != strings.ToLower(got) {
|
||||||
|
t.Errorf("expected lowercase hexadecimal, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSHA256FileReportsAMissingFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "not-there")
|
||||||
|
|
||||||
|
got, err := SHA256File(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected an error for a file that does not exist, got %q", got)
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected an error that os.IsNotExist recognises, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSHA256FileReportsADirectory(t *testing.T) {
|
||||||
|
if _, err := SHA256File(t.TempDir()); err == nil {
|
||||||
|
t.Errorf("expected an error for a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
36
slugify.go
Normal file
36
slugify.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Slugify returns input as lowercase ASCII letters, digits and hyphens, which
|
||||||
|
// is safe to put in a URL path, a filename or a fragment identifier. Every run
|
||||||
|
// of other characters becomes a single hyphen, and the result never starts or
|
||||||
|
// ends with one.
|
||||||
|
//
|
||||||
|
// Characters outside ASCII are treated as separators rather than being folded
|
||||||
|
// to their nearest ASCII relative, because doing that properly needs a Unicode
|
||||||
|
// table that this package does not carry. Text with no ASCII letters or digits
|
||||||
|
// in it therefore slugifies to an empty string.
|
||||||
|
func Slugify(input string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
pendingHyphen := false
|
||||||
|
|
||||||
|
for _, character := range strings.ToLower(input) {
|
||||||
|
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
|
||||||
|
if pendingHyphen {
|
||||||
|
out.WriteByte('-')
|
||||||
|
pendingHyphen = false
|
||||||
|
}
|
||||||
|
out.WriteRune(character)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember that a separator was seen, but only write a hyphen once
|
||||||
|
// another usable character turns up, so nothing trails off the end.
|
||||||
|
if out.Len() > 0 {
|
||||||
|
pendingHyphen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
34
slugify_test.go
Normal file
34
slugify_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSlugify(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"already a slug", "hello-world", "hello-world"},
|
||||||
|
{"mixed case", "Hello World", "hello-world"},
|
||||||
|
{"punctuation", "What's new?", "what-s-new"},
|
||||||
|
{"run of separators", "one --- two", "one-two"},
|
||||||
|
{"leading separators", "///one", "one"},
|
||||||
|
{"trailing separators", "one///", "one"},
|
||||||
|
{"separators at both ends", " --one two-- ", "one-two"},
|
||||||
|
{"digits are kept", "Go 1.14 release", "go-1-14-release"},
|
||||||
|
{"underscores are separators", "some_name_here", "some-name-here"},
|
||||||
|
{"nothing usable", "!!! ???", ""},
|
||||||
|
{"empty string", "", ""},
|
||||||
|
{"outside ascii", "Grüße, Welt", "gr-e-welt"},
|
||||||
|
{"only characters outside ascii", "日本語", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := Slugify(test.input)
|
||||||
|
if got != test.expected {
|
||||||
|
t.Errorf("expected %q got %q", test.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
21
truncate.go
Normal file
21
truncate.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
// TruncateString returns at most max characters from the start of s. It counts
|
||||||
|
// characters rather than bytes, so a multi-byte character is never cut in half
|
||||||
|
// and the result is always valid text. A max of zero or less returns an empty
|
||||||
|
// string, and a string already at or below the limit is returned unchanged.
|
||||||
|
func TruncateString(s string, max int) string {
|
||||||
|
if max <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for offset := range s {
|
||||||
|
if count == max {
|
||||||
|
return s[:offset]
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
41
truncate_test.go
Normal file
41
truncate_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTruncateString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
max int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"shorter than the limit", "abc", 5, "abc"},
|
||||||
|
{"exactly at the limit", "abc", 3, "abc"},
|
||||||
|
{"longer than the limit", "abcdef", 3, "abc"},
|
||||||
|
{"max of one", "abc", 1, "a"},
|
||||||
|
{"max of zero", "abc", 0, ""},
|
||||||
|
{"negative max", "abc", -1, ""},
|
||||||
|
{"empty string", "", 3, ""},
|
||||||
|
{"multi-byte characters kept whole", "héllo", 3, "hél"},
|
||||||
|
{"multi-byte characters below the limit", "héllo", 99, "héllo"},
|
||||||
|
{"characters outside the basic plane", "a😀b", 2, "a😀"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got := TruncateString(test.input, test.max)
|
||||||
|
if got != test.expected {
|
||||||
|
t.Errorf("expected %q got %q", test.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateStringCountsCharactersNotBytes(t *testing.T) {
|
||||||
|
// Each of these characters is two bytes long, so a byte-based cut at
|
||||||
|
// three would leave half a character behind.
|
||||||
|
got := TruncateString("äöü", 3)
|
||||||
|
if got != "äöü" {
|
||||||
|
t.Errorf("expected %q got %q", "äöü", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user