初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "bytes"
|
||||
|
||||
func BufioScannerSplit(b byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
// reference: bufio.ScanLines
|
||||
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexByte(data, b); i >= 0 {
|
||||
return i + 1, data[0:i], nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HexToRBGColor parses color as RGB values in 0..255 range from the hex color string (with or without #)
|
||||
func HexToRBGColor(colorString string) (float64, float64, float64) {
|
||||
hexString := colorString
|
||||
if strings.HasPrefix(colorString, "#") {
|
||||
hexString = colorString[1:]
|
||||
}
|
||||
// only support transfer of rgb, rgba, rrggbb and rrggbbaa
|
||||
// if not in these formats, use default values 0, 0, 0
|
||||
if len(hexString) != 3 && len(hexString) != 4 && len(hexString) != 6 && len(hexString) != 8 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
if len(hexString) == 3 || len(hexString) == 4 {
|
||||
hexString = fmt.Sprintf("%c%c%c%c%c%c", hexString[0], hexString[0], hexString[1], hexString[1], hexString[2], hexString[2])
|
||||
}
|
||||
if len(hexString) == 8 {
|
||||
hexString = hexString[0:6]
|
||||
}
|
||||
color, err := strconv.ParseUint(hexString, 16, 32)
|
||||
color32 := uint32(color)
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
r := float64(uint8(0xFF & (color32 >> 16)))
|
||||
g := float64(uint8(0xFF & (color32 >> 8)))
|
||||
b := float64(uint8(0xFF & color32))
|
||||
return r, g, b
|
||||
}
|
||||
|
||||
// GetRelativeLuminance returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
|
||||
// Keep this in sync with web_src/js/utils/color.js
|
||||
func GetRelativeLuminance(color string) float64 {
|
||||
r, g, b := HexToRBGColor(color)
|
||||
return (0.2126729*r + 0.7151522*g + 0.0721750*b) / 255
|
||||
}
|
||||
|
||||
func UseLightText(backgroundColor string) bool {
|
||||
return GetRelativeLuminance(backgroundColor) < 0.453
|
||||
}
|
||||
|
||||
// ContrastColor returns a black or white foreground color that the highest contrast ratio.
|
||||
// In the future, the APCA contrast function, or CSS `contrast-color` will be better.
|
||||
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
|
||||
func ContrastColor(backgroundColor string) string {
|
||||
if UseLightText(backgroundColor) {
|
||||
return "#fff"
|
||||
}
|
||||
return "#000"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_HexToRBGColor(t *testing.T) {
|
||||
cases := []struct {
|
||||
colorString string
|
||||
expectedR float64
|
||||
expectedG float64
|
||||
expectedB float64
|
||||
}{
|
||||
{"2b8685", 43, 134, 133},
|
||||
{"1e1", 17, 238, 17},
|
||||
{"#1e1", 17, 238, 17},
|
||||
{"1e16", 17, 238, 17},
|
||||
{"3bb6b3", 59, 182, 179},
|
||||
{"#3bb6b399", 59, 182, 179},
|
||||
{"#0", 0, 0, 0},
|
||||
{"#00000", 0, 0, 0},
|
||||
{"#1234567", 0, 0, 0},
|
||||
}
|
||||
for n, c := range cases {
|
||||
r, g, b := HexToRBGColor(c.colorString)
|
||||
assert.InDelta(t, c.expectedR, r, 0, "case %d: error R should match: expected %f, but get %f", n, c.expectedR, r)
|
||||
assert.InDelta(t, c.expectedG, g, 0, "case %d: error G should match: expected %f, but get %f", n, c.expectedG, g)
|
||||
assert.InDelta(t, c.expectedB, b, 0, "case %d: error B should match: expected %f, but get %f", n, c.expectedB, b)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_UseLightText(t *testing.T) {
|
||||
cases := []struct {
|
||||
color string
|
||||
expected string
|
||||
}{
|
||||
{"#d73a4a", "#fff"},
|
||||
{"#0075ca", "#fff"},
|
||||
{"#cfd3d7", "#000"},
|
||||
{"#a2eeef", "#000"},
|
||||
{"#7057ff", "#fff"},
|
||||
{"#008672", "#fff"},
|
||||
{"#e4e669", "#000"},
|
||||
{"#d876e3", "#000"},
|
||||
{"#ffffff", "#000"},
|
||||
{"#2b8684", "#fff"},
|
||||
{"#2b8786", "#fff"},
|
||||
{"#2c8786", "#000"},
|
||||
{"#3bb6b3", "#000"},
|
||||
{"#7c7268", "#fff"},
|
||||
{"#7e716c", "#fff"},
|
||||
{"#81706d", "#fff"},
|
||||
{"#807070", "#fff"},
|
||||
{"#84b6eb", "#000"},
|
||||
}
|
||||
for n, c := range cases {
|
||||
assert.Equal(t, c.expected, ContrastColor(c.color), "case %d: error should match", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDiffSliceBasic(t *testing.T) {
|
||||
// Typical integer cases
|
||||
t.Run("additions", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2}, []int{1, 2, 3})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("removals", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2, 3}, []int{1, 2})
|
||||
assert.Empty(t, added)
|
||||
assert.Equal(t, []int{3}, removed)
|
||||
})
|
||||
|
||||
t.Run("no changes", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2}, []int{1, 2})
|
||||
assert.Empty(t, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("empty slices", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{}, []int{})
|
||||
assert.Empty(t, added)
|
||||
assert.Empty(t, removed)
|
||||
})
|
||||
|
||||
t.Run("overlapping elements", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 2, 4}, []int{2, 3, 4})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Equal(t, []int{1}, removed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffSliceOrderAndDuplicates(t *testing.T) {
|
||||
oldSlice := []int{1, 2, 2, 3}
|
||||
newSlice := []int{2, 4, 2, 5}
|
||||
|
||||
added, removed := DiffSlice(oldSlice, newSlice)
|
||||
assert.Equal(t, []int{4, 5}, added)
|
||||
assert.Equal(t, []int{1, 3}, removed)
|
||||
}
|
||||
|
||||
func TestDiffSliceDeduplicatesOutput(t *testing.T) {
|
||||
// Test case from issue: newSlice contains [4, 4, 5] and oldSlice is [1]
|
||||
// added should return [4, 5], not [4, 4, 5]
|
||||
t.Run("deduplicates added", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1}, []int{4, 4, 5})
|
||||
assert.Equal(t, []int{4, 5}, added)
|
||||
assert.Equal(t, []int{1}, removed)
|
||||
})
|
||||
|
||||
t.Run("deduplicates removed", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 1, 2}, []int{3})
|
||||
assert.Equal(t, []int{3}, added)
|
||||
assert.Equal(t, []int{1, 2}, removed)
|
||||
})
|
||||
|
||||
t.Run("deduplicates both", func(t *testing.T) {
|
||||
added, removed := DiffSlice([]int{1, 1, 2, 2}, []int{3, 3, 4, 4})
|
||||
assert.Equal(t, []int{3, 4}, added)
|
||||
assert.Equal(t, []int{1, 2}, removed)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// Common Errors forming the base of our error system
|
||||
//
|
||||
// Many Errors returned by Gitea can be tested against these errors using "errors.Is".
|
||||
var (
|
||||
ErrInvalidArgument = errors.New("invalid argument") // also implies HTTP 400
|
||||
ErrPermissionDenied = errors.New("permission denied") // also implies HTTP 403
|
||||
ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404
|
||||
ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409
|
||||
ErrContentTooLarge = errors.New("content exceeds limit") // also implies HTTP 413
|
||||
|
||||
// ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct,
|
||||
// but the server is unable to process the contained instructions
|
||||
ErrUnprocessableContent = errors.New("unprocessable content")
|
||||
)
|
||||
|
||||
// errorWrapper provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message
|
||||
// Especially useful for "untyped" errors created with "errors.New(…)" that can be classified as 'invalid argument', 'permission denied', 'exists already', or 'does not exist'
|
||||
type errorWrapper struct {
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the message
|
||||
func (w errorWrapper) Error() string {
|
||||
return w.Message
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error
|
||||
func (w errorWrapper) Unwrap() error {
|
||||
return w.Err
|
||||
}
|
||||
|
||||
// ErrorWrap returns an error that formats as the given text but unwraps as the provided error
|
||||
func ErrorWrap(unwrap error, message string, args ...any) error {
|
||||
if len(args) == 0 {
|
||||
return errorWrapper{Message: message, Err: unwrap}
|
||||
}
|
||||
return errorWrapper{Message: fmt.Sprintf(message, args...), Err: unwrap}
|
||||
}
|
||||
|
||||
// NewInvalidArgumentErrorf returns an error that formats as the given text but unwraps as an ErrInvalidArgument
|
||||
func NewInvalidArgumentErrorf(message string, args ...any) error {
|
||||
return ErrorWrap(ErrInvalidArgument, message, args...)
|
||||
}
|
||||
|
||||
// NewPermissionDeniedErrorf returns an error that formats as the given text but unwraps as an ErrPermissionDenied
|
||||
func NewPermissionDeniedErrorf(message string, args ...any) error {
|
||||
return ErrorWrap(ErrPermissionDenied, message, args...)
|
||||
}
|
||||
|
||||
// NewAlreadyExistErrorf returns an error that formats as the given text but unwraps as an ErrAlreadyExist
|
||||
func NewAlreadyExistErrorf(message string, args ...any) error {
|
||||
return ErrorWrap(ErrAlreadyExist, message, args...)
|
||||
}
|
||||
|
||||
// NewNotExistErrorf returns an error that formats as the given text but unwraps as an ErrNotExist
|
||||
func NewNotExistErrorf(message string, args ...any) error {
|
||||
return ErrorWrap(ErrNotExist, message, args...)
|
||||
}
|
||||
|
||||
// ErrorTranslatable wraps an error with translation information
|
||||
type ErrorTranslatable interface {
|
||||
error
|
||||
Unwrap() error
|
||||
Translate(ErrorLocaleTranslator) template.HTML
|
||||
}
|
||||
|
||||
type errorTranslatableWrapper struct {
|
||||
err error
|
||||
trKey string
|
||||
trArgs []any
|
||||
}
|
||||
|
||||
type ErrorLocaleTranslator interface {
|
||||
Tr(key string, args ...any) template.HTML
|
||||
}
|
||||
|
||||
func (w *errorTranslatableWrapper) Error() string { return w.err.Error() }
|
||||
|
||||
func (w *errorTranslatableWrapper) Unwrap() error { return w.err }
|
||||
|
||||
func (w *errorTranslatableWrapper) Translate(t ErrorLocaleTranslator) template.HTML {
|
||||
return t.Tr(w.trKey, w.trArgs...)
|
||||
}
|
||||
|
||||
func ErrorWrapTranslatable(err error, trKey string, trArgs ...any) ErrorTranslatable {
|
||||
return &errorTranslatableWrapper{err: err, trKey: trKey, trArgs: trArgs}
|
||||
}
|
||||
|
||||
func ErrorAsTranslatable(err error) ErrorTranslatable {
|
||||
var e *errorTranslatableWrapper
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorTranslatable(t *testing.T) {
|
||||
var err error
|
||||
|
||||
err = ErrorWrapTranslatable(io.EOF, "key", 1)
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
assert.Equal(t, "EOF", err.Error())
|
||||
assert.Equal(t, "key", err.(*errorTranslatableWrapper).trKey)
|
||||
assert.Equal(t, []any{1}, err.(*errorTranslatableWrapper).trArgs)
|
||||
|
||||
err = ErrorWrap(err, "new msg %d", 100)
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
assert.Equal(t, "new msg 100", err.Error())
|
||||
|
||||
errTr := ErrorAsTranslatable(err)
|
||||
assert.Equal(t, "EOF", errTr.Error())
|
||||
assert.Equal(t, "key", errTr.(*errorTranslatableWrapper).trKey)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var defaultUmask int
|
||||
|
||||
func init() {
|
||||
// at the moment, the umask could only be gotten by calling unix.Umask(newUmask)
|
||||
// use 0o077 as temp new umask to reduce the risks if this umask is used anywhere else before the correct umask is recovered
|
||||
tempUmask := 0o077
|
||||
defaultUmask = unix.Umask(tempUmask)
|
||||
unix.Umask(defaultUmask)
|
||||
}
|
||||
|
||||
func ApplyUmask(f string, newMode os.FileMode) error {
|
||||
mod := newMode & ^os.FileMode(defaultUmask)
|
||||
return os.Chmod(f, mod)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplyUmask(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "test-filemode-")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = os.Chmod(f.Name(), 0o777)
|
||||
assert.NoError(t, err)
|
||||
st, err := os.Stat(f.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0o777, st.Mode().Perm()&0o777)
|
||||
|
||||
oldDefaultUmask := defaultUmask
|
||||
defaultUmask = 0o037
|
||||
defer func() {
|
||||
defaultUmask = oldDefaultUmask
|
||||
}()
|
||||
err = ApplyUmask(f.Name(), os.ModePerm)
|
||||
assert.NoError(t, err)
|
||||
st, err = os.Stat(f.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 0o740, st.Mode().Perm()&0o777)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func ApplyUmask(f string, newMode os.FileMode) error {
|
||||
// do nothing for Windows, because Windows doesn't use umask
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package filebuffer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
var ErrWriteAfterRead = errors.New("write is unsupported after a read operation") // occurs if Write is called after a read operation
|
||||
|
||||
type readAtSeeker interface {
|
||||
io.ReadSeeker
|
||||
io.ReaderAt
|
||||
}
|
||||
|
||||
// FileBackedBuffer uses a memory buffer with a fixed size.
|
||||
// If more data is written a temporary file is used instead.
|
||||
// It implements io.ReadWriteCloser, io.ReadSeekCloser and io.ReaderAt
|
||||
type FileBackedBuffer struct {
|
||||
maxMemorySize int64
|
||||
size int64
|
||||
buffer bytes.Buffer
|
||||
tempDir string
|
||||
file *os.File
|
||||
reader readAtSeeker
|
||||
}
|
||||
|
||||
// New creates a file backed buffer with a specific maximum memory size
|
||||
func New(maxMemorySize int, tempDir string) *FileBackedBuffer {
|
||||
return &FileBackedBuffer{
|
||||
maxMemorySize: int64(maxMemorySize),
|
||||
tempDir: tempDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (b *FileBackedBuffer) Write(p []byte) (int, error) {
|
||||
if b.reader != nil {
|
||||
return 0, ErrWriteAfterRead
|
||||
}
|
||||
|
||||
var n int
|
||||
var err error
|
||||
|
||||
if b.file != nil {
|
||||
n, err = b.file.Write(p)
|
||||
} else {
|
||||
if b.size+int64(len(p)) > b.maxMemorySize {
|
||||
b.file, err = os.CreateTemp(b.tempDir, "gitea-buffer-")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(b.file, &b.buffer)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return b.Write(p)
|
||||
}
|
||||
|
||||
n, err = b.buffer.Write(p)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
b.size += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Size returns the byte size of the buffered data
|
||||
func (b *FileBackedBuffer) Size() int64 {
|
||||
return b.size
|
||||
}
|
||||
|
||||
func (b *FileBackedBuffer) switchToReader() error {
|
||||
if b.reader != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if b.file != nil {
|
||||
if _, err := b.file.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
b.reader = b.file
|
||||
} else {
|
||||
b.reader = bytes.NewReader(b.buffer.Bytes())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read implements io.Reader
|
||||
func (b *FileBackedBuffer) Read(p []byte) (int, error) {
|
||||
if err := b.switchToReader(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return b.reader.Read(p)
|
||||
}
|
||||
|
||||
// ReadAt implements io.ReaderAt
|
||||
func (b *FileBackedBuffer) ReadAt(p []byte, off int64) (int, error) {
|
||||
if err := b.switchToReader(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return b.reader.ReadAt(p, off)
|
||||
}
|
||||
|
||||
// Seek implements io.Seeker
|
||||
func (b *FileBackedBuffer) Seek(offset int64, whence int) (int64, error) {
|
||||
if err := b.switchToReader(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return b.reader.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// Close implements io.Closer
|
||||
func (b *FileBackedBuffer) Close() error {
|
||||
if b.file != nil {
|
||||
err := b.file.Close()
|
||||
_ = os.Remove(b.file.Name())
|
||||
b.file = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package filebuffer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileBackedBuffer(t *testing.T) {
|
||||
cases := []struct {
|
||||
MaxMemorySize int
|
||||
Data string
|
||||
}{
|
||||
{5, "test"},
|
||||
{5, "testtest"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
buf := New(c.MaxMemorySize, t.TempDir())
|
||||
_, err := io.Copy(buf, strings.NewReader(c.Data))
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, len(c.Data), buf.Size())
|
||||
|
||||
data, err := io.ReadAll(buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c.Data, string(data))
|
||||
|
||||
assert.NoError(t, buf.Close())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type NopCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (NopCloser) Close() error { return nil }
|
||||
|
||||
// ReadAtMost reads at most len(buf) bytes from r into buf.
|
||||
// It returns the number of bytes copied. n is only less than len(buf) if r provides fewer bytes.
|
||||
// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
|
||||
func ReadAtMost(r io.Reader, buf []byte) (n int, err error) {
|
||||
n, err = io.ReadFull(r, buf)
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
err = nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadWithLimit reads at most "limit" bytes from r into buf.
|
||||
// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
|
||||
func ReadWithLimit(r io.Reader, n int) (buf []byte, err error) {
|
||||
return readWithLimit(r, 4*1024, n)
|
||||
}
|
||||
|
||||
func readWithLimit(r io.Reader, batch, limit int) ([]byte, error) {
|
||||
if limit <= batch {
|
||||
buf := make([]byte, limit)
|
||||
n, err := ReadAtMost(r, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
res := bytes.NewBuffer(make([]byte, 0, batch))
|
||||
bufFix := make([]byte, batch)
|
||||
eof := false
|
||||
for res.Len() < limit && !eof {
|
||||
bufTmp := bufFix
|
||||
if res.Len()+batch > limit {
|
||||
bufTmp = bufFix[:limit-res.Len()]
|
||||
}
|
||||
n, err := io.ReadFull(r, bufTmp)
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
eof = true
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = res.Write(bufTmp[:n]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res.Bytes(), nil
|
||||
}
|
||||
|
||||
// ErrNotEmpty is an error reported when there is a non-empty reader
|
||||
var ErrNotEmpty = errors.New("not-empty")
|
||||
|
||||
// IsEmptyReader reads a reader and ensures it is empty
|
||||
func IsEmptyReader(r io.Reader) (err error) {
|
||||
var buf [1]byte
|
||||
|
||||
for {
|
||||
n, err := r.Read(buf[:])
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrNotEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CountingReader struct {
|
||||
io.Reader
|
||||
n int
|
||||
}
|
||||
|
||||
var _ io.Reader = &CountingReader{}
|
||||
|
||||
func (w *CountingReader) Count() int {
|
||||
return w.n
|
||||
}
|
||||
|
||||
func (w *CountingReader) Read(p []byte) (int, error) {
|
||||
n, err := w.Reader.Read(p)
|
||||
w.n += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func NewCountingReader(rd io.Reader) *CountingReader {
|
||||
return &CountingReader{Reader: rd}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type readerWithError struct {
|
||||
buf *bytes.Buffer
|
||||
}
|
||||
|
||||
func (r *readerWithError) Read(p []byte) (n int, err error) {
|
||||
if r.buf.Len() < 2 {
|
||||
return 0, errors.New("test error")
|
||||
}
|
||||
return r.buf.Read(p)
|
||||
}
|
||||
|
||||
func TestReadWithLimit(t *testing.T) {
|
||||
bs := []byte("0123456789abcdef")
|
||||
|
||||
// normal test
|
||||
buf, err := readWithLimit(bytes.NewBuffer(bs), 5, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("01"), buf)
|
||||
|
||||
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("01234"), buf)
|
||||
|
||||
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 6)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("012345"), buf)
|
||||
|
||||
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, len(bs))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("0123456789abcdef"), buf)
|
||||
|
||||
buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 100)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("0123456789abcdef"), buf)
|
||||
|
||||
// test with error
|
||||
buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("0123456789"), buf)
|
||||
|
||||
buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 100)
|
||||
assert.ErrorContains(t, err, "test error")
|
||||
assert.Empty(t, buf)
|
||||
|
||||
// test public function
|
||||
buf, err = ReadWithLimit(bytes.NewBuffer(bs), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("01"), buf)
|
||||
|
||||
buf, err = ReadWithLimit(bytes.NewBuffer(bs), 9999999)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("0123456789abcdef"), buf)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
)
|
||||
|
||||
// GenerateKeyPair generates a public and private keypair
|
||||
func GenerateKeyPair(bits int) (string, string, error) {
|
||||
priv, _ := rsa.GenerateKey(rand.Reader, bits)
|
||||
privPem := pemBlockForPriv(priv)
|
||||
pubPem, err := pemBlockForPub(&priv.PublicKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return privPem, pubPem, nil
|
||||
}
|
||||
|
||||
func pemBlockForPriv(priv *rsa.PrivateKey) string {
|
||||
privBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(priv),
|
||||
})
|
||||
return string(privBytes)
|
||||
}
|
||||
|
||||
func pemBlockForPub(pub *rsa.PublicKey) (string, error) {
|
||||
pubASN1, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pubBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubASN1,
|
||||
})
|
||||
return string(pubBytes), nil
|
||||
}
|
||||
|
||||
// CreatePublicKeyFingerprint creates a fingerprint of the given key.
|
||||
// The fingerprint is the sha256 sum of the PKIX structure of the key.
|
||||
func CreatePublicKeyFingerprint(key crypto.PublicKey) ([]byte, error) {
|
||||
bytes, err := x509.MarshalPKIXPublicKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checksum := sha256.Sum256(bytes)
|
||||
|
||||
return checksum[:], nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeygen(t *testing.T) {
|
||||
priv, pub, err := GenerateKeyPair(2048)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, priv)
|
||||
assert.NotEmpty(t, pub)
|
||||
|
||||
assert.Regexp(t, "^-----BEGIN RSA PRIVATE KEY-----.*", priv)
|
||||
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----.*", pub)
|
||||
}
|
||||
|
||||
func TestSignUsingKeys(t *testing.T) {
|
||||
priv, pub, err := GenerateKeyPair(2048)
|
||||
assert.NoError(t, err)
|
||||
|
||||
privPem, _ := pem.Decode([]byte(priv))
|
||||
if privPem == nil || privPem.Type != "RSA PRIVATE KEY" {
|
||||
t.Fatal("key is wrong type")
|
||||
}
|
||||
|
||||
privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pubPem, _ := pem.Decode([]byte(pub))
|
||||
if pubPem == nil || pubPem.Type != "PUBLIC KEY" {
|
||||
t.Fatal("key failed to decode")
|
||||
}
|
||||
|
||||
pubParsed, err := x509.ParsePKIXPublicKey(pubPem.Bytes)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Sign
|
||||
msg := "activity pub is great!"
|
||||
h := sha256.New()
|
||||
h.Write([]byte(msg))
|
||||
d := h.Sum(nil)
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, privParsed, crypto.SHA256, d)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
err = rsa.VerifyPKCS1v15(pubParsed.(*rsa.PublicKey), crypto.SHA256, d, sig)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CopyFile copies file from source to target path.
|
||||
func CopyFile(src, dest string) error {
|
||||
si, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sr, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sr.Close()
|
||||
|
||||
dw, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dw.Close()
|
||||
|
||||
if _, err = io.Copy(dw, sr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Chtimes(dest, si.ModTime(), si.ModTime()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dest, si.Mode())
|
||||
}
|
||||
|
||||
// AESGCMEncrypt (from legacy package): encrypts plaintext with the given key using AES in GCM mode. should be replaced.
|
||||
func AESGCMEncrypt(key, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
return append(nonce, ciphertext...), nil
|
||||
}
|
||||
|
||||
// AESGCMDecrypt (from legacy package): decrypts ciphertext with the given key using AES in GCM mode. should be replaced.
|
||||
func AESGCMDecrypt(key, ciphertext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
size := gcm.NonceSize()
|
||||
if len(ciphertext)-size <= 0 {
|
||||
return nil, errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
nonce := ciphertext[:size]
|
||||
ciphertext = ciphertext[size:]
|
||||
|
||||
plainText, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plainText, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
testContent := []byte("hello")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
now := time.Now()
|
||||
srcFile := fmt.Sprintf("%s/copy-test-%d-src.txt", tmpDir, now.UnixMicro())
|
||||
dstFile := fmt.Sprintf("%s/copy-test-%d-dst.txt", tmpDir, now.UnixMicro())
|
||||
|
||||
_ = os.Remove(srcFile)
|
||||
_ = os.Remove(dstFile)
|
||||
defer func() {
|
||||
_ = os.Remove(srcFile)
|
||||
_ = os.Remove(dstFile)
|
||||
}()
|
||||
|
||||
err := os.WriteFile(srcFile, testContent, 0o777)
|
||||
assert.NoError(t, err)
|
||||
err = CopyFile(srcFile, dstFile)
|
||||
assert.NoError(t, err)
|
||||
dstContent, err := os.ReadFile(dstFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testContent, dstContent)
|
||||
}
|
||||
|
||||
func TestAESGCM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := make([]byte, aes.BlockSize)
|
||||
_, err := rand.Read(key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
plaintext := []byte("this will be encrypted")
|
||||
|
||||
ciphertext, err := AESGCMEncrypt(key, plaintext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decrypted, err := AESGCMDecrypt(key, ciphertext)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
func GetMapValueOrDefault[T any](m map[string]any, key string, defaultValue T) T {
|
||||
if value, ok := m[key]; ok {
|
||||
if v, ok := value.(T); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMapValueOrDefault(t *testing.T) {
|
||||
testMap := map[string]any{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"key3": nil,
|
||||
}
|
||||
|
||||
assert.Equal(t, "value1", GetMapValueOrDefault(testMap, "key1", "default"))
|
||||
assert.Equal(t, 42, GetMapValueOrDefault(testMap, "key2", 0))
|
||||
|
||||
assert.Equal(t, "default", GetMapValueOrDefault(testMap, "key4", "default"))
|
||||
assert.Equal(t, 100, GetMapValueOrDefault(testMap, "key5", 100))
|
||||
|
||||
assert.Equal(t, "default", GetMapValueOrDefault(testMap, "key3", "default"))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
)
|
||||
|
||||
// PackData uses gob to encode the given data in sequence
|
||||
func PackData(data ...any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
for _, datum := range data {
|
||||
if err := enc.Encode(datum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnpackData uses gob to decode the given data in sequence
|
||||
func UnpackData(buf []byte, data ...any) error {
|
||||
r := bytes.NewReader(buf)
|
||||
enc := gob.NewDecoder(r)
|
||||
for _, datum := range data {
|
||||
if err := enc.Decode(datum); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPackAndUnpackData(t *testing.T) {
|
||||
s := "string"
|
||||
i := int64(4)
|
||||
f := float32(4.1)
|
||||
|
||||
var s2 string
|
||||
var i2 int64
|
||||
var f2 float32
|
||||
|
||||
data, err := PackData(s, i, f)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, UnpackData(data, &s2, &i2, &f2))
|
||||
assert.NoError(t, UnpackData(data, &s2))
|
||||
assert.Error(t, UnpackData(data, &i2))
|
||||
assert.Error(t, UnpackData(data, &s2, &f2))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "reflect"
|
||||
|
||||
// PaginateSlice cut a slice as per pagination options
|
||||
// if page = 0 it do not paginate
|
||||
func PaginateSlice(list any, page, pageSize int) any {
|
||||
if page <= 0 || pageSize <= 0 {
|
||||
return list
|
||||
}
|
||||
if reflect.TypeOf(list).Kind() != reflect.Slice {
|
||||
return list
|
||||
}
|
||||
|
||||
listValue := reflect.ValueOf(list)
|
||||
|
||||
page--
|
||||
|
||||
if page*pageSize >= listValue.Len() {
|
||||
return listValue.Slice(listValue.Len(), listValue.Len()).Interface()
|
||||
}
|
||||
|
||||
listValue = listValue.Slice(page*pageSize, listValue.Len())
|
||||
|
||||
if listValue.Len() > pageSize {
|
||||
return listValue.Slice(0, pageSize).Interface()
|
||||
}
|
||||
|
||||
return listValue.Interface()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPaginateSlice(t *testing.T) {
|
||||
stringSlice := []string{"a", "b", "c", "d", "e"}
|
||||
result, ok := PaginateSlice(stringSlice, 1, 2).([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"a", "b"}, result)
|
||||
|
||||
result, ok = PaginateSlice(stringSlice, 100, 2).([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{}, result)
|
||||
|
||||
result, ok = PaginateSlice(stringSlice, 3, 2).([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"e"}, result)
|
||||
|
||||
result, ok = PaginateSlice(stringSlice, 1, 0).([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"a", "b", "c", "d", "e"}, result)
|
||||
|
||||
result, ok = PaginateSlice(stringSlice, 1, -1).([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []string{"a", "b", "c", "d", "e"}, result)
|
||||
|
||||
type Test struct {
|
||||
Val int
|
||||
}
|
||||
|
||||
testVar := []*Test{{Val: 2}, {Val: 3}, {Val: 4}}
|
||||
testVar, ok = PaginateSlice(testVar, 1, 50).([]*Test)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []*Test{{Val: 2}, {Val: 3}, {Val: 4}}, testVar)
|
||||
|
||||
testVar, ok = PaginateSlice(testVar, 2, 2).([]*Test)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []*Test{{Val: 4}}, testVar)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PathJoinRel joins the path elements into a single path, each element is cleaned by path.Clean separately.
|
||||
// It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed.
|
||||
// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
|
||||
//
|
||||
// empty => ``
|
||||
// `` => ``
|
||||
// `..` => `.`
|
||||
// `dir` => `dir`
|
||||
// `/dir/` => `dir`
|
||||
// `foo\..\bar` => `foo\..\bar`
|
||||
// {`foo`, ``, `bar`} => `foo/bar`
|
||||
// {`foo`, `..`, `bar`} => `foo/bar`
|
||||
func PathJoinRel(elem ...string) string {
|
||||
elems := make([]string, len(elem))
|
||||
for i, e := range elem {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
elems[i] = path.Clean("/" + e)
|
||||
}
|
||||
p := path.Join(elems...)
|
||||
switch p {
|
||||
case "":
|
||||
return ""
|
||||
case "/":
|
||||
return "."
|
||||
}
|
||||
return p[1:]
|
||||
}
|
||||
|
||||
// PathJoinRelX joins the path elements into a single path like PathJoinRel,
|
||||
// and covert all backslashes to slashes. (X means "extended", also means the combination of `\` and `/`).
|
||||
// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
|
||||
// It returns similar results as PathJoinRel except:
|
||||
//
|
||||
// `foo\..\bar` => `bar` (because it's processed as `foo/../bar`)
|
||||
//
|
||||
// All backslashes are handled as slashes, the result only contains slashes.
|
||||
func PathJoinRelX(elem ...string) string {
|
||||
elems := make([]string, len(elem))
|
||||
for i, e := range elem {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
elems[i] = path.Clean("/" + strings.ReplaceAll(e, "\\", "/"))
|
||||
}
|
||||
return PathJoinRel(elems...)
|
||||
}
|
||||
|
||||
const filepathSeparator = string(os.PathSeparator)
|
||||
|
||||
// FilePathJoinAbs joins the path elements into a single file path, each element is cleaned by filepath.Clean separately.
|
||||
// All slashes/backslashes are converted to path separators before cleaning, the result only contains path separators.
|
||||
// The first element must be an absolute path, caller should prepare the base path.
|
||||
// It's caller's duty to make every element not bypass its own directly level, to avoid security issues.
|
||||
// Like PathJoinRel, any redundant part (empty, relative dots, slashes) is removed.
|
||||
//
|
||||
// {`/foo`, ``, `bar`} => `/foo/bar`
|
||||
// {`/foo`, `..`, `bar`} => `/foo/bar`
|
||||
func FilePathJoinAbs(base string, sub ...string) string {
|
||||
// POSIX filesystem can have `\` in file names. Windows: `\` and `/` are both used for path separators
|
||||
// to keep the behavior consistent, we do not allow `\` in file names, replace all `\` with `/`
|
||||
if !isOSWindows() {
|
||||
base = strings.ReplaceAll(base, "\\", filepathSeparator)
|
||||
}
|
||||
if !filepath.IsAbs(base) {
|
||||
// This shouldn't happen. If it is really necessary to handle relative paths, use filepath.Abs() to get absolute paths first
|
||||
panic(fmt.Sprintf("FilePathJoinAbs: %q (for path %v) is not absolute, do not guess a relative path based on current working directory", base, sub))
|
||||
}
|
||||
if len(sub) == 0 {
|
||||
return filepath.Clean(base)
|
||||
}
|
||||
|
||||
elems := make([]string, 1, len(sub)+1)
|
||||
elems[0] = base
|
||||
for _, s := range sub {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if isOSWindows() {
|
||||
elems = append(elems, filepath.Clean(filepathSeparator+s))
|
||||
} else {
|
||||
elems = append(elems, filepath.Clean(filepathSeparator+strings.ReplaceAll(s, "\\", filepathSeparator)))
|
||||
}
|
||||
}
|
||||
// the elems[0] must be an absolute path, just join them together, and Join will also do Clean
|
||||
return filepath.Join(elems...)
|
||||
}
|
||||
|
||||
// IsDir returns true if given path is a directory,
|
||||
// or returns false when it's a file or does not exist.
|
||||
func IsDir(dir string) (bool, error) {
|
||||
f, err := os.Stat(dir)
|
||||
if err == nil {
|
||||
return f.IsDir(), nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
var ErrNotRegularPathFile = errors.New("not a regular file")
|
||||
|
||||
// ReadRegularPathFile reads a file with given sub path in root dir.
|
||||
// It returns error when the path is not a regular file, or any parent path is not a regular directory.
|
||||
func ReadRegularPathFile(root, filePathIn string, limit int) ([]byte, error) {
|
||||
pathFields := strings.Split(PathJoinRelX(filePathIn), "/")
|
||||
|
||||
targetPathBuilder := strings.Builder{}
|
||||
targetPathBuilder.Grow(len(root) + len(filePathIn) + 2)
|
||||
targetPathBuilder.WriteString(root)
|
||||
targetPathString := root
|
||||
for i, subPath := range pathFields {
|
||||
targetPathBuilder.WriteByte(filepath.Separator)
|
||||
targetPathBuilder.WriteString(subPath)
|
||||
targetPathString = targetPathBuilder.String()
|
||||
|
||||
expectFile := i == len(pathFields)-1
|
||||
st, err := os.Lstat(targetPathString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectFile && !st.Mode().IsRegular() || !expectFile && !st.Mode().IsDir() {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotRegularPathFile, filePathIn)
|
||||
}
|
||||
}
|
||||
f, err := os.Open(targetPathString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ReadWithLimit(f, limit)
|
||||
}
|
||||
|
||||
// WriteRegularPathFile writes data to a file with given sub path in root dir, it creates parent directories if necessary.
|
||||
// The file is created with fileMode, and the directories are created with dirMode.
|
||||
// It returns error when the path already exists but is not a regular file, or any parent path is not a regular directory.
|
||||
func WriteRegularPathFile(root, filePathIn string, data []byte, dirMode, fileMode os.FileMode) error {
|
||||
pathFields := strings.Split(PathJoinRelX(filePathIn), "/")
|
||||
|
||||
targetPathBuilder := strings.Builder{}
|
||||
targetPathBuilder.Grow(len(root) + len(filePathIn) + 2)
|
||||
targetPathBuilder.WriteString(root)
|
||||
targetPathString := root
|
||||
for i, subPath := range pathFields {
|
||||
targetPathBuilder.WriteByte(filepath.Separator)
|
||||
targetPathBuilder.WriteString(subPath)
|
||||
targetPathString = targetPathBuilder.String()
|
||||
|
||||
expectFile := i == len(pathFields)-1
|
||||
st, err := os.Lstat(targetPathString)
|
||||
if err == nil {
|
||||
if expectFile && !st.Mode().IsRegular() || !expectFile && !st.Mode().IsDir() {
|
||||
return fmt.Errorf("%w: %s", ErrNotRegularPathFile, filePathIn)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if !expectFile {
|
||||
if err = os.Mkdir(targetPathString, dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.WriteFile(targetPathString, data, fileMode)
|
||||
}
|
||||
|
||||
// IsExist checks whether a file or directory exists.
|
||||
// It returns false when the file or directory does not exist.
|
||||
func IsExist(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil || os.IsExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func listDirRecursively(result *[]string, fsDir, recordParentPath string, opts *ListDirOptions) error {
|
||||
dir, err := os.Open(fsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
fis, err := dir.Readdir(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fi := range fis {
|
||||
if opts.SkipCommonHiddenNames && IsCommonHiddenFileName(fi.Name()) {
|
||||
continue
|
||||
}
|
||||
relPath := path.Join(recordParentPath, fi.Name())
|
||||
curPath := filepath.Join(fsDir, fi.Name())
|
||||
if fi.IsDir() {
|
||||
if opts.IncludeDir {
|
||||
*result = append(*result, relPath+"/")
|
||||
}
|
||||
if err = listDirRecursively(result, curPath, relPath, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
*result = append(*result, relPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListDirOptions struct {
|
||||
IncludeDir bool // subdirectories are also included with suffix slash
|
||||
SkipCommonHiddenNames bool
|
||||
}
|
||||
|
||||
// ListDirRecursively gathers information of given directory by depth-first.
|
||||
// The paths are always in "dir/slash/file" format (not "\\" even in Windows)
|
||||
// Slice does not include given path itself.
|
||||
func ListDirRecursively(rootDir string, opts *ListDirOptions) (res []string, err error) {
|
||||
if err = listDirRecursively(&res, rootDir, "", opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func isOSWindows() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
|
||||
var driveLetterRegexp = regexp.MustCompile("/[A-Za-z]:/")
|
||||
|
||||
// FileURLToPath extracts the path information from a file://... url.
|
||||
// It returns an error only if the URL is not a file URL.
|
||||
func FileURLToPath(u *url.URL) (string, error) {
|
||||
if u.Scheme != "file" {
|
||||
return "", errors.New("URL scheme is not 'file': " + u.String())
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
|
||||
if !isOSWindows() {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// If it looks like there's a Windows drive letter at the beginning, strip off the leading slash.
|
||||
if driveLetterRegexp.MatchString(path) {
|
||||
return path[1:], nil
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// HomeDir returns path of '~'(in Linux) on Windows,
|
||||
// it returns error when the variable does not exist.
|
||||
func HomeDir() (home string, err error) {
|
||||
// TODO: some users run Gitea with mismatched uid and "HOME=xxx" (they set HOME=xxx by environment manually)
|
||||
// TODO: when running gitea as a sub command inside git, the HOME directory is not the user's home directory
|
||||
// so at the moment we can not use `user.Current().HomeDir`
|
||||
if isOSWindows() {
|
||||
home = os.Getenv("USERPROFILE")
|
||||
if home == "" {
|
||||
home = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH")
|
||||
}
|
||||
} else {
|
||||
home = os.Getenv("HOME")
|
||||
}
|
||||
|
||||
if home == "" {
|
||||
return "", errors.New("cannot get home directory")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
|
||||
// IsCommonHiddenFileName will check a provided name to see if it represents file or directory that should not be watched
|
||||
func IsCommonHiddenFileName(name string) bool {
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch name[0] {
|
||||
case '.':
|
||||
return true
|
||||
case 't', 'T':
|
||||
return name[1:] == "humbs.db" // macOS
|
||||
case 'd', 'D':
|
||||
return name[1:] == "esktop.ini" // Windows
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsReadmeFileName reports whether name looks like a README file
|
||||
// based on its name.
|
||||
func IsReadmeFileName(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
if len(name) < 6 {
|
||||
return false
|
||||
} else if len(name) == 6 {
|
||||
return name == "readme"
|
||||
}
|
||||
return name[:7] == "readme."
|
||||
}
|
||||
|
||||
// IsReadmeFileExtension reports whether name looks like a README file
|
||||
// based on its name. It will look through the provided extensions and check if the file matches
|
||||
// one of the extensions and provide the index in the extension list.
|
||||
// If the filename is `readme.` with an unmatched extension it will match with the index equaling
|
||||
// the length of the provided extension list.
|
||||
// Note that the '.' should be provided in ext, e.g ".md"
|
||||
func IsReadmeFileExtension(name string, ext ...string) (int, bool) {
|
||||
name = strings.ToLower(name)
|
||||
if len(name) < 6 || name[:6] != "readme" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for i, extension := range ext {
|
||||
extension = strings.ToLower(extension)
|
||||
if name[6:] == extension {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
|
||||
if name[6] == '.' {
|
||||
return len(ext), true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFileURLToPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
expected string
|
||||
haserror bool
|
||||
windows bool
|
||||
}{
|
||||
// case 0
|
||||
{
|
||||
url: "",
|
||||
haserror: true,
|
||||
},
|
||||
// case 1
|
||||
{
|
||||
url: "http://test.io",
|
||||
haserror: true,
|
||||
},
|
||||
// case 2
|
||||
{
|
||||
url: "file:///path",
|
||||
expected: "/path",
|
||||
},
|
||||
// case 3
|
||||
{
|
||||
url: "file:///C:/path",
|
||||
expected: "C:/path",
|
||||
windows: true,
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cases {
|
||||
if c.windows && runtime.GOOS != "windows" {
|
||||
continue
|
||||
}
|
||||
u, _ := url.Parse(c.url)
|
||||
p, err := FileURLToPath(u)
|
||||
if c.haserror {
|
||||
assert.Error(t, err, "case %d: should return error", n)
|
||||
} else {
|
||||
assert.NoError(t, err, "case %d: should not return error", n)
|
||||
assert.Equal(t, c.expected, p, "case %d: should be equal", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMisc_IsReadmeFileName(t *testing.T) {
|
||||
trueTestCases := []string{
|
||||
"readme",
|
||||
"README",
|
||||
"readME.mdown",
|
||||
"README.md",
|
||||
"readme.i18n.md",
|
||||
}
|
||||
falseTestCases := []string{
|
||||
"test.md",
|
||||
"wow.MARKDOWN",
|
||||
"LOL.mDoWn",
|
||||
"test",
|
||||
"abcdefg",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
"test.md.test",
|
||||
"readmf",
|
||||
}
|
||||
|
||||
for _, testCase := range trueTestCases {
|
||||
assert.True(t, IsReadmeFileName(testCase))
|
||||
}
|
||||
for _, testCase := range falseTestCases {
|
||||
assert.False(t, IsReadmeFileName(testCase))
|
||||
}
|
||||
|
||||
type extensionTestcase struct {
|
||||
name string
|
||||
expected bool
|
||||
idx int
|
||||
}
|
||||
|
||||
exts := []string{".md", ".txt", ""}
|
||||
testCasesExtensions := []extensionTestcase{
|
||||
{
|
||||
name: "readme",
|
||||
expected: true,
|
||||
idx: 2,
|
||||
},
|
||||
{
|
||||
name: "readme.md",
|
||||
expected: true,
|
||||
idx: 0,
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
expected: true,
|
||||
idx: 0,
|
||||
},
|
||||
{
|
||||
name: "ReAdMe.Md",
|
||||
expected: true,
|
||||
idx: 0,
|
||||
},
|
||||
{
|
||||
name: "readme.txt",
|
||||
expected: true,
|
||||
idx: 1,
|
||||
},
|
||||
{
|
||||
name: "readme.doc",
|
||||
expected: true,
|
||||
idx: 3,
|
||||
},
|
||||
{
|
||||
name: "readmee.md",
|
||||
},
|
||||
{
|
||||
name: "readme..",
|
||||
expected: true,
|
||||
idx: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCasesExtensions {
|
||||
idx, ok := IsReadmeFileExtension(testCase.name, exts...)
|
||||
assert.Equal(t, testCase.expected, ok)
|
||||
assert.Equal(t, testCase.idx, idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
elems []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{}, ``},
|
||||
{[]string{``}, ``},
|
||||
{[]string{`..`}, `.`},
|
||||
{[]string{`a`}, `a`},
|
||||
{[]string{`/a/`}, `a`},
|
||||
{[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
|
||||
{[]string{`a\..\b`}, `a\..\b`},
|
||||
{[]string{`a`, ``, `b`}, `a/b`},
|
||||
{[]string{`a`, `..`, `b`}, `a/b`},
|
||||
{[]string{`lfs`, `repo/..`, `user/../path`}, `lfs/path`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expected, PathJoinRel(c.elems...), "case: %v", c.elems)
|
||||
}
|
||||
|
||||
cases = []struct {
|
||||
elems []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{}, ``},
|
||||
{[]string{``}, ``},
|
||||
{[]string{`..`}, `.`},
|
||||
{[]string{`a`}, `a`},
|
||||
{[]string{`/a/`}, `a`},
|
||||
{[]string{`../a/`, `../b`, `c/..`, `d`}, `a/b/d`},
|
||||
{[]string{`a\..\b`}, `b`},
|
||||
{[]string{`a`, ``, `b`}, `a/b`},
|
||||
{[]string{`a`, `..`, `b`}, `a/b`},
|
||||
{[]string{`lfs`, `repo/..`, `user/../path`}, `lfs/path`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expected, PathJoinRelX(c.elems...), "case: %v", c.elems)
|
||||
}
|
||||
|
||||
// for POSIX only, but the result is similar on Windows, because the first element must be an absolute path
|
||||
if isOSWindows() {
|
||||
cases = []struct {
|
||||
elems []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{`C:\..`}, `C:\`},
|
||||
{[]string{`C:\a`}, `C:\a`},
|
||||
{[]string{`C:\a/`}, `C:\a`},
|
||||
{[]string{`C:\..\a\`, `../b`, `c\..`, `d`}, `C:\a\b\d`},
|
||||
{[]string{`C:\a/..\b`}, `C:\b`},
|
||||
{[]string{`C:\a`, ``, `b`}, `C:\a\b`},
|
||||
{[]string{`C:\a`, `..`, `b`}, `C:\a\b`},
|
||||
{[]string{`C:\lfs`, `repo/..`, `user/../path`}, `C:\lfs\path`},
|
||||
}
|
||||
} else {
|
||||
cases = []struct {
|
||||
elems []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{`/..`}, `/`},
|
||||
{[]string{`/a`}, `/a`},
|
||||
{[]string{`/a/`}, `/a`},
|
||||
{[]string{`/../a/`, `../b`, `c/..`, `d`}, `/a/b/d`},
|
||||
{[]string{`/a\..\b`}, `/b`},
|
||||
{[]string{`/a`, ``, `b`}, `/a/b`},
|
||||
{[]string{`/a`, `..`, `b`}, `/a/b`},
|
||||
{[]string{`/lfs`, `repo/..`, `user/../path`}, `/lfs/path`},
|
||||
}
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expected, FilePathJoinAbs(c.elems[0], c.elems[1:]...), "case: %v", c.elems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirRecursively(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_ = os.WriteFile(tmpDir+"/.config", nil, 0o644)
|
||||
_ = os.Mkdir(tmpDir+"/d1", 0o755)
|
||||
_ = os.WriteFile(tmpDir+"/d1/f-d1", nil, 0o644)
|
||||
_ = os.Mkdir(tmpDir+"/d1/s1", 0o755)
|
||||
_ = os.WriteFile(tmpDir+"/d1/s1/f-d1s1", nil, 0o644)
|
||||
_ = os.Mkdir(tmpDir+"/d2", 0o755)
|
||||
|
||||
res, err := ListDirRecursively(tmpDir, &ListDirOptions{IncludeDir: true})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{".config", "d1/", "d1/f-d1", "d1/s1/", "d1/s1/f-d1s1", "d2/"}, res)
|
||||
|
||||
res, err = ListDirRecursively(tmpDir, &ListDirOptions{SkipCommonHiddenNames: true})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"d1/f-d1", "d1/s1/f-d1s1"}, res)
|
||||
}
|
||||
|
||||
func TestReadWriteRegularPathFile(t *testing.T) {
|
||||
const readLimit = 10000
|
||||
tmpDir := t.TempDir()
|
||||
rootDir := tmpDir + "/root"
|
||||
_ = os.Mkdir(rootDir, 0o755)
|
||||
_ = os.WriteFile(tmpDir+"/other-file", []byte("other-content"), 0o755)
|
||||
_ = os.Mkdir(rootDir+"/real-dir", 0o755)
|
||||
_ = os.WriteFile(rootDir+"/real-dir/real-file", []byte("dummy-content"), 0o644)
|
||||
_ = os.Symlink(rootDir+"/real-dir", rootDir+"/link-dir")
|
||||
_ = os.Symlink(rootDir+"/real-dir/real-file", rootDir+"/real-dir/link-file")
|
||||
|
||||
t.Run("Read", func(t *testing.T) {
|
||||
content, err := os.ReadFile(filepath.Join(rootDir, "../other-file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other-content", string(content))
|
||||
|
||||
content, err = ReadRegularPathFile(rootDir, "../other-file", readLimit)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Empty(t, string(content))
|
||||
|
||||
content, err = ReadRegularPathFile(rootDir, "real-dir/real-file", readLimit)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dummy-content", string(content))
|
||||
|
||||
_, err = ReadRegularPathFile(rootDir, "link-dir/real-file", readLimit)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
_, err = ReadRegularPathFile(rootDir, "real-dir/link-file", readLimit)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
_, err = ReadRegularPathFile(rootDir, "link-dir/link-file", readLimit)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
})
|
||||
|
||||
t.Run("Write", func(t *testing.T) {
|
||||
assertFileContent := func(path, expected string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if expected == "" {
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, string(data), "file content mismatch for %s", path)
|
||||
}
|
||||
|
||||
err := WriteRegularPathFile(rootDir, "new-dir/new-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.NoError(t, err)
|
||||
assertFileContent(rootDir+"/new-dir/new-file", "new-content")
|
||||
|
||||
err = WriteRegularPathFile(rootDir, "link-dir/real-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
err = WriteRegularPathFile(rootDir, "link-dir/link-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
err = WriteRegularPathFile(rootDir, "link-dir/new-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
err = WriteRegularPathFile(rootDir, "real-dir/link-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.ErrorIs(t, err, ErrNotRegularPathFile)
|
||||
|
||||
err = WriteRegularPathFile(rootDir, "../other-file", []byte("new-content"), 0o755, 0o644)
|
||||
require.NoError(t, err)
|
||||
assertFileContent(rootDir+"/../other-file", "other-content")
|
||||
assertFileContent(rootDir+"/other-file", "new-content")
|
||||
|
||||
err = WriteRegularPathFile(rootDir, "real-dir/real-file", []byte("changed-content"), 0o755, 0o644)
|
||||
require.NoError(t, err)
|
||||
assertFileContent(rootDir+"/real-dir/real-file", "changed-content")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const windowsSharingViolationError syscall.Errno = 32
|
||||
|
||||
// Remove removes the named file or (empty) directory with at most 5 attempts.
|
||||
func Remove(name string) error {
|
||||
var err error
|
||||
for range 5 {
|
||||
err = os.Remove(name)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
unwrapped := err.(*os.PathError).Err
|
||||
if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if unwrapped == syscall.ENOENT {
|
||||
// it's already gone
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveAll removes the named file or (empty) directory with at most 5 attempts.
|
||||
func RemoveAll(name string) error {
|
||||
var err error
|
||||
for range 5 {
|
||||
err = os.RemoveAll(name)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
unwrapped := err.(*os.PathError).Err
|
||||
if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if unwrapped == syscall.ENOENT {
|
||||
// it's already gone
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename renames (moves) oldpath to newpath with at most 5 attempts.
|
||||
func Rename(oldpath, newpath string) error {
|
||||
var err error
|
||||
for i := range 5 {
|
||||
err = os.Rename(oldpath, newpath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
unwrapped := err.(*os.LinkError).Err
|
||||
if unwrapped == syscall.EBUSY || unwrapped == syscall.ENOTEMPTY || unwrapped == syscall.EPERM || unwrapped == syscall.EMFILE || unwrapped == syscall.ENFILE {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if unwrapped == windowsSharingViolationError && runtime.GOOS == "windows" {
|
||||
// try again
|
||||
<-time.After(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if i == 0 && os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if unwrapped == syscall.ENOENT {
|
||||
// it's already gone
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rotatingfilewriter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/graceful/releasereopen"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Rotate bool
|
||||
MaximumSize int64
|
||||
RotateDaily bool
|
||||
KeepDays int
|
||||
Compress bool
|
||||
CompressionLevel int
|
||||
}
|
||||
|
||||
type RotatingFileWriter struct {
|
||||
mu sync.Mutex
|
||||
fd *os.File
|
||||
|
||||
currentSize int64
|
||||
openDate int
|
||||
|
||||
options Options
|
||||
|
||||
cancelReleaseReopen func()
|
||||
}
|
||||
|
||||
var ErrorPrintf func(format string, args ...any)
|
||||
|
||||
// errorf tries to print error messages. Since this writer could be used by a logger system, this is the last chance to show the error in some cases
|
||||
func errorf(format string, args ...any) {
|
||||
if ErrorPrintf != nil {
|
||||
ErrorPrintf("rotatingfilewriter: "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Open creates a new rotating file writer.
|
||||
// Notice: if a file is opened by two rotators, there will be conflicts when rotating.
|
||||
// In the future, there should be "rotating file manager"
|
||||
func Open(filename string, options *Options) (*RotatingFileWriter, error) {
|
||||
if options == nil {
|
||||
options = &Options{}
|
||||
}
|
||||
|
||||
rfw := &RotatingFileWriter{
|
||||
options: *options,
|
||||
}
|
||||
|
||||
if err := rfw.open(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rfw.cancelReleaseReopen = releasereopen.GetManager().Register(rfw)
|
||||
return rfw, nil
|
||||
}
|
||||
|
||||
func (rfw *RotatingFileWriter) Write(b []byte) (int, error) {
|
||||
if rfw.options.Rotate && ((rfw.options.MaximumSize > 0 && rfw.currentSize >= rfw.options.MaximumSize) || (rfw.options.RotateDaily && time.Now().Day() != rfw.openDate)) {
|
||||
if err := rfw.DoRotate(); err != nil {
|
||||
// if this writer is used by a logger system, it's the logger system's responsibility to handle/show the error
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
n, err := rfw.fd.Write(b)
|
||||
if err == nil {
|
||||
rfw.currentSize += int64(n)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (rfw *RotatingFileWriter) Flush() error {
|
||||
return rfw.fd.Sync()
|
||||
}
|
||||
|
||||
func (rfw *RotatingFileWriter) Close() error {
|
||||
rfw.mu.Lock()
|
||||
if rfw.cancelReleaseReopen != nil {
|
||||
rfw.cancelReleaseReopen()
|
||||
rfw.cancelReleaseReopen = nil
|
||||
}
|
||||
rfw.mu.Unlock()
|
||||
return rfw.fd.Close()
|
||||
}
|
||||
|
||||
func (rfw *RotatingFileWriter) open(filename string) error {
|
||||
fd, err := os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o660)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rfw.fd = fd
|
||||
|
||||
finfo, err := fd.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rfw.currentSize = finfo.Size()
|
||||
rfw.openDate = finfo.ModTime().Day()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rfw *RotatingFileWriter) ReleaseReopen() error {
|
||||
return errors.Join(
|
||||
rfw.fd.Close(),
|
||||
rfw.open(rfw.fd.Name()),
|
||||
)
|
||||
}
|
||||
|
||||
// DoRotate the log file creating a backup like xx.2013-01-01.2
|
||||
func (rfw *RotatingFileWriter) DoRotate() error {
|
||||
if !rfw.options.Rotate {
|
||||
return nil
|
||||
}
|
||||
|
||||
rfw.mu.Lock()
|
||||
defer rfw.mu.Unlock()
|
||||
|
||||
prefix := fmt.Sprintf("%s.%s.", rfw.fd.Name(), time.Now().Format("2006-01-02"))
|
||||
|
||||
var err error
|
||||
fname := ""
|
||||
for i := 1; err == nil && i <= 999; i++ {
|
||||
fname = prefix + fmt.Sprintf("%03d", i)
|
||||
_, err = os.Lstat(fname)
|
||||
if rfw.options.Compress && err != nil {
|
||||
_, err = os.Lstat(fname + ".gz")
|
||||
}
|
||||
}
|
||||
// return error if the last file checked still existed
|
||||
if err == nil {
|
||||
return fmt.Errorf("cannot find free file to rename %s", rfw.fd.Name())
|
||||
}
|
||||
|
||||
fd := rfw.fd
|
||||
if err := fd.Close(); err != nil { // close file before rename
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.Rename(fd.Name(), fname); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rfw.options.Compress {
|
||||
go func() {
|
||||
err := compressOldFile(fname, rfw.options.CompressionLevel)
|
||||
if err != nil {
|
||||
errorf("DoRotate: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := rfw.open(fd.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go deleteOldFiles(
|
||||
filepath.Dir(fd.Name()),
|
||||
filepath.Base(fd.Name()),
|
||||
time.Now().AddDate(0, 0, -rfw.options.KeepDays),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func compressOldFile(fname string, compressionLevel int) error {
|
||||
reader, err := os.Open(fname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compressOldFile: failed to open existing file %s: %w", fname, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
fnameGz := fname + ".gz"
|
||||
fw, err := os.OpenFile(fnameGz, os.O_WRONLY|os.O_CREATE, 0o660)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compressOldFile: failed to open new file %s: %w", fnameGz, err)
|
||||
}
|
||||
defer fw.Close()
|
||||
|
||||
zw, err := gzip.NewWriterLevel(fw, compressionLevel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compressOldFile: failed to create gzip writer: %w", err)
|
||||
}
|
||||
defer zw.Close()
|
||||
|
||||
_, err = buffer.WriteTo(zw)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
_ = fw.Close()
|
||||
_ = util.Remove(fname + ".gz")
|
||||
return fmt.Errorf("compressOldFile: failed to write to gz file: %w", err)
|
||||
}
|
||||
_ = reader.Close()
|
||||
|
||||
err = util.Remove(fname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compressOldFile: failed to delete old file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteOldFiles(dir, prefix string, removeBefore time.Time) {
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) (returnErr error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
returnErr = fmt.Errorf("unable to delete old file '%s', error: %+v", path, r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ModTime().Before(removeBefore) {
|
||||
if strings.HasPrefix(filepath.Base(path), prefix) {
|
||||
return util.Remove(path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
errorf("deleteOldFiles: failed to delete old file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rotatingfilewriter
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCompressOldFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fname := filepath.Join(tmpDir, "test")
|
||||
nonGzip := filepath.Join(tmpDir, "test-nonGzip")
|
||||
|
||||
f, err := os.OpenFile(fname, os.O_CREATE|os.O_WRONLY, 0o660)
|
||||
assert.NoError(t, err)
|
||||
ng, err := os.OpenFile(nonGzip, os.O_CREATE|os.O_WRONLY, 0o660)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for range 999 {
|
||||
f.WriteString("This is a test file\n")
|
||||
ng.WriteString("This is a test file\n")
|
||||
}
|
||||
f.Close()
|
||||
ng.Close()
|
||||
|
||||
err = compressOldFile(fname, gzip.DefaultCompression)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = os.Lstat(fname + ".gz")
|
||||
assert.NoError(t, err)
|
||||
|
||||
f, err = os.Open(fname + ".gz")
|
||||
assert.NoError(t, err)
|
||||
zr, err := gzip.NewReader(f)
|
||||
assert.NoError(t, err)
|
||||
data, err := io.ReadAll(zr)
|
||||
assert.NoError(t, err)
|
||||
original, err := os.ReadFile(nonGzip)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, original, data)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "runtime"
|
||||
|
||||
func CallerFuncName(optSkipParent ...int) string {
|
||||
pc := make([]uintptr, 1)
|
||||
skipParent := 0
|
||||
if len(optSkipParent) > 0 {
|
||||
skipParent = optSkipParent[0]
|
||||
}
|
||||
runtime.Callers(skipParent+1 /*this*/ +1 /*runtime*/, pc)
|
||||
funcName := runtime.FuncForPC(pc[0]).Name()
|
||||
return funcName
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCallerFuncName(t *testing.T) {
|
||||
s := CallerFuncName()
|
||||
assert.Equal(t, "gitea.dev/modules/util.TestCallerFuncName", s)
|
||||
}
|
||||
|
||||
func BenchmarkCallerFuncName(b *testing.B) {
|
||||
// BenchmarkCaller/sprintf-12 12744829 95.49 ns/op
|
||||
b.Run("sprintf", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = fmt.Sprintf("aaaaaaaaaaaaaaaa %s %s %s", "bbbbbbbbbbbbbbbbbbb", b.Name(), "ccccccccccccccccccccc")
|
||||
}
|
||||
})
|
||||
// BenchmarkCaller/caller-12 10625133 113.6 ns/op
|
||||
// It is almost as fast as fmt.Sprintf
|
||||
b.Run("caller", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
CallerFuncName()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type sanitizedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (err sanitizedError) Error() string {
|
||||
return SanitizeCredentialURLs(err.err.Error())
|
||||
}
|
||||
|
||||
func (err sanitizedError) Unwrap() error {
|
||||
return err.err
|
||||
}
|
||||
|
||||
// SanitizeErrorCredentialURLs wraps the error and make sure the returned error message doesn't contain sensitive credentials in URLs
|
||||
func SanitizeErrorCredentialURLs(err error) error {
|
||||
return sanitizedError{err: err}
|
||||
}
|
||||
|
||||
var schemeSep = []byte("://")
|
||||
|
||||
const userInfoPlaceholder = "(masked)"
|
||||
|
||||
// SanitizeCredentialURLs remove all credentials in URLs for the input string:
|
||||
// * "https://userinfo@domain.com" => "https://***@domain.com"
|
||||
// * "user:pass@domain.com" => "***@domain.com"
|
||||
// "***" is a magic string internally used, doesn't guarantee to be anything.
|
||||
func SanitizeCredentialURLs(s string) string {
|
||||
sepColPos := strings.Index(s, ":")
|
||||
if sepColPos == -1 {
|
||||
return s // fast path: no colon, unlikely contain any URL credential
|
||||
}
|
||||
sepAtPos := strings.Index(s[sepColPos+1:], "@")
|
||||
for sepAtPos == -1 {
|
||||
return s // fast path: no "@" after colon, unlikely contain any URL credential
|
||||
}
|
||||
sepAtPos += sepColPos + 1
|
||||
|
||||
res := make([]byte, 0, len(s)+len(userInfoPlaceholder)) // a best guess to avoid too many re-allocations
|
||||
bs := UnsafeStringToBytes(s)
|
||||
for {
|
||||
// left part (before "@") is likely to be the "userinfo" (single username, or "username:password")
|
||||
leftPos := sepAtPos - 1
|
||||
leftLoop:
|
||||
for leftPos >= 0 {
|
||||
c := bs[leftPos]
|
||||
switch c {
|
||||
case '-', '.', '_', '~', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '%':
|
||||
// RFC 3986, userinfo can contain - . _ ~ ! $ & ' ( ) * + , ; = : and any percent-encoded chars
|
||||
default:
|
||||
valid := 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
|
||||
if !valid {
|
||||
break leftLoop
|
||||
}
|
||||
}
|
||||
leftPos--
|
||||
}
|
||||
// left pos should point to the beginning of the left part, this pos is always valid in the buffer
|
||||
leftPos++
|
||||
|
||||
// right part is likely to be the host (domain name, ip address)
|
||||
rightPos := sepAtPos + 1
|
||||
rightLoop:
|
||||
for rightPos < len(bs) {
|
||||
c := bs[rightPos]
|
||||
switch c {
|
||||
case '.', '-':
|
||||
// valid host char
|
||||
case '[':
|
||||
// ipv6 begin
|
||||
if rightPos != sepAtPos+1 {
|
||||
break rightLoop
|
||||
}
|
||||
case ']':
|
||||
// ipv6 end
|
||||
rightPos++
|
||||
break rightLoop
|
||||
default:
|
||||
valid := 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9'
|
||||
if bs[sepAtPos+1] == '[' {
|
||||
// ipv6 host
|
||||
valid = 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' || '0' <= c && c <= '9' || c == ':'
|
||||
}
|
||||
if !valid {
|
||||
break rightLoop
|
||||
}
|
||||
}
|
||||
rightPos++
|
||||
}
|
||||
|
||||
leading, leftPart, rightPart := bs[:leftPos], bs[leftPos:sepAtPos], bs[sepAtPos+1:rightPos]
|
||||
|
||||
// Either:
|
||||
// * git log message: "user:pass@host" (it contains a colon in userinfo), ignore "git@host" pattern
|
||||
// * http like URL: "https://userinfo@host.com" (it has "://" before the userinfo)
|
||||
needSanitize := bytes.IndexByte(leftPart, ':') >= 0 || bytes.HasSuffix(leading, schemeSep)
|
||||
needSanitize = needSanitize && len(leftPart) > 0 && len(rightPart) > 0
|
||||
// TODO: can also do more checks for right part
|
||||
// for example: ipv6 quick check
|
||||
if needSanitize && rightPart[0] == '[' {
|
||||
needSanitize = rightPart[len(rightPart)-1] == ']' && net.ParseIP(UnsafeBytesToString(rightPart[1:len(rightPart)-1])) != nil
|
||||
}
|
||||
if needSanitize {
|
||||
res = append(res, leading...)
|
||||
res = append(res, userInfoPlaceholder...)
|
||||
res = append(res, '@')
|
||||
res = append(res, rightPart...)
|
||||
} else {
|
||||
res = append(res, bs[:rightPos]...)
|
||||
}
|
||||
bs = bs[rightPos:]
|
||||
sepAtPos = bytes.IndexByte(bs, '@')
|
||||
if sepAtPos == -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
res = append(res, bs...)
|
||||
return UnsafeBytesToString(res)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSanitizeErrorCredentialURLs(t *testing.T) {
|
||||
err := errors.New("error with https://a@b.com")
|
||||
se := SanitizeErrorCredentialURLs(err)
|
||||
assert.Equal(t, "error with https://"+userInfoPlaceholder+"@b.com", se.Error())
|
||||
}
|
||||
|
||||
func TestSanitizeCredentialURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"https://github.com/go-gitea/test_repo.git",
|
||||
"https://github.com/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
"https://mytoken@github.com/go-gitea/test_repo.git",
|
||||
"https://" + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
"https://user:password@github.com/go-gitea/test_repo.git",
|
||||
"https://" + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
"https://user:password@[::]/go-gitea/test_repo.git",
|
||||
"https://" + userInfoPlaceholder + "@[::]/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
"https://user:password@[2001:db8::1]:8080/go-gitea/test_repo.git",
|
||||
"https://" + userInfoPlaceholder + "@[2001:db8::1]:8080/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
"see https://u:p@[::1]/x and https://u2:p2@h2",
|
||||
"see https://" + userInfoPlaceholder + "@[::1]/x and https://" + userInfoPlaceholder + "@h2",
|
||||
},
|
||||
{
|
||||
"https://user:secret@[unclosed-ipv6",
|
||||
"https://user:secret@[unclosed-ipv6",
|
||||
},
|
||||
{
|
||||
"https://user:secret@[invalid-ipv6]",
|
||||
"https://user:secret@[invalid-ipv6]",
|
||||
},
|
||||
{
|
||||
"ftp://x@",
|
||||
"ftp://x@",
|
||||
},
|
||||
{
|
||||
"ftp://x/@",
|
||||
"ftp://x/@",
|
||||
},
|
||||
{
|
||||
"ftp://u@x/@", // test multiple @ chars
|
||||
"ftp://" + userInfoPlaceholder + "@x/@",
|
||||
},
|
||||
{
|
||||
"😊ftp://u@x😊", // test unicode
|
||||
"😊ftp://" + userInfoPlaceholder + "@x😊",
|
||||
},
|
||||
{
|
||||
"://@",
|
||||
"://@",
|
||||
},
|
||||
{
|
||||
"//u:p@h",
|
||||
"//" + userInfoPlaceholder + "@h",
|
||||
},
|
||||
{
|
||||
"s://u@h",
|
||||
"s://" + userInfoPlaceholder + "@h",
|
||||
},
|
||||
{
|
||||
"URLs in log https://u:b@h and https://u:b@h:80/, with https://h.com and u@h.com",
|
||||
"URLs in log https://" + userInfoPlaceholder + "@h and https://" + userInfoPlaceholder + "@h:80/, with https://h.com and u@h.com",
|
||||
},
|
||||
{
|
||||
"fatal: unable to look up username:token@github.com (port 9418)",
|
||||
"fatal: unable to look up " + userInfoPlaceholder + "@github.com (port 9418)",
|
||||
},
|
||||
{
|
||||
"git failed for user:token@github.com/go-gitea/test_repo.git",
|
||||
"git failed for " + userInfoPlaceholder + "@github.com/go-gitea/test_repo.git",
|
||||
},
|
||||
{
|
||||
// SSH-form git URL ("git@host:path") must not let a later credential URL through
|
||||
"failed remote git@github.com:foo, retried via https://user:tok@github.com/foo",
|
||||
"failed remote git@github.com:foo, retried via https://" + userInfoPlaceholder + "@github.com/foo",
|
||||
},
|
||||
}
|
||||
|
||||
for n, c := range cases {
|
||||
result := SanitizeCredentialURLs(c.input)
|
||||
assert.Equal(t, c.expected, result, "case %d: error should match", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2022 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SecToHours converts an amount of seconds to a human-readable hours string.
|
||||
// This is stable for planning and managing timesheets.
|
||||
// Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours.
|
||||
// If the duration is less than 1 minute, it will be shown as seconds.
|
||||
func SecToHours(durationVal any) string {
|
||||
seconds, _ := ToInt64(durationVal)
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds / 60) % 60
|
||||
|
||||
formattedTime := ""
|
||||
formattedTime = formatTime(hours, "hour", formattedTime)
|
||||
formattedTime = formatTime(minutes, "minute", formattedTime)
|
||||
|
||||
// The formatTime() function always appends a space at the end. This will be trimmed
|
||||
if formattedTime == "" && seconds > 0 {
|
||||
formattedTime = formatTime(seconds, "second", "")
|
||||
}
|
||||
return strings.TrimRight(formattedTime, " ")
|
||||
}
|
||||
|
||||
// formatTime appends the given value to the existing forammattedTime. E.g:
|
||||
// formattedTime = "1 year"
|
||||
// input: value = 3, name = "month"
|
||||
// output will be "1 year 3 months "
|
||||
func formatTime(value int64, name, formattedTime string) string {
|
||||
if value == 1 {
|
||||
formattedTime = fmt.Sprintf("%s1 %s ", formattedTime, name)
|
||||
} else if value > 1 {
|
||||
formattedTime = fmt.Sprintf("%s%d %ss ", formattedTime, value, name)
|
||||
}
|
||||
return formattedTime
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2022 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSecToHours(t *testing.T) {
|
||||
second := int64(1)
|
||||
minute := 60 * second
|
||||
hour := 60 * minute
|
||||
day := 24 * hour
|
||||
|
||||
assert.Equal(t, "1 minute", SecToHours(minute+6*second))
|
||||
assert.Equal(t, "1 hour", SecToHours(hour))
|
||||
assert.Equal(t, "1 hour", SecToHours(hour+second))
|
||||
assert.Equal(t, "14 hours 33 minutes", SecToHours(14*hour+33*minute+30*second))
|
||||
assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
|
||||
assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second))
|
||||
assert.Equal(t, "672 hours", SecToHours(4*7*day))
|
||||
assert.Equal(t, "1 second", SecToHours(1))
|
||||
assert.Equal(t, "2 seconds", SecToHours(2))
|
||||
assert.Empty(t, SecToHours(nil)) // old behavior, empty means no output
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// Bash has the definition of a metacharacter:
|
||||
// * A character that, when unquoted, separates words.
|
||||
// A metacharacter is one of: " \t\n|&;()<>"
|
||||
//
|
||||
// The following characters also have addition special meaning when unescaped:
|
||||
// * ‘${[*?!"'`\’
|
||||
//
|
||||
// Double Quotes preserve the literal value of all characters with then quotes
|
||||
// excepting: ‘$’, ‘`’, ‘\’, and, when history expansion is enabled, ‘!’.
|
||||
// The backslash retains its special meaning only when followed by one of the
|
||||
// following characters: ‘$’, ‘`’, ‘"’, ‘\’, or newline.
|
||||
// Backslashes preceding characters without a special meaning are left
|
||||
// unmodified. A double quote may be quoted within double quotes by preceding
|
||||
// it with a backslash. If enabled, history expansion will be performed unless
|
||||
// an ‘!’ appearing in double quotes is escaped using a backslash. The
|
||||
// backslash preceding the ‘!’ is not removed.
|
||||
//
|
||||
// -> This means that `!\n` cannot be safely expressed in `"`.
|
||||
//
|
||||
// Looking at the man page for Dash and ash the situation is similar.
|
||||
//
|
||||
// Now zsh requires that ‘}’, and ‘]’ are also enclosed in doublequotes or escaped
|
||||
//
|
||||
// Single quotes escape everything except a ‘'’
|
||||
//
|
||||
// There's one other gotcha - ‘~’ at the start of a string needs to be expanded
|
||||
// because people always expect that - of course if there is a special character before '/'
|
||||
// this is not going to work
|
||||
|
||||
const (
|
||||
tildePrefix = '~'
|
||||
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
|
||||
needsSingleQuote = "!\n"
|
||||
)
|
||||
|
||||
var (
|
||||
doubleQuoteEscaper = strings.NewReplacer(`$`, `\$`, "`", "\\`", `"`, `\"`, `\`, `\\`)
|
||||
singleQuoteEscaper = strings.NewReplacer(`'`, `'\''`)
|
||||
singleQuoteCoalescer = strings.NewReplacer(`''\'`, `\'`, `\'''`, `\'`)
|
||||
)
|
||||
|
||||
// ShellEscape will escape the provided string.
|
||||
// We can't just use go-shellquote here because our preferences for escaping differ from those in that we want:
|
||||
//
|
||||
// * If the string doesn't require any escaping just leave it as it is.
|
||||
// * If the string requires any escaping prefer double quote escaping
|
||||
// * If we have ! or newlines then we need to use single quote escaping
|
||||
func ShellEscape(toEscape string) string {
|
||||
if len(toEscape) == 0 {
|
||||
return toEscape
|
||||
}
|
||||
|
||||
start := 0
|
||||
|
||||
if toEscape[0] == tildePrefix {
|
||||
// We're in the forcibly non-escaped section...
|
||||
idx := strings.IndexRune(toEscape, '/')
|
||||
if idx < 0 {
|
||||
idx = len(toEscape)
|
||||
} else {
|
||||
idx++
|
||||
}
|
||||
if !strings.ContainsAny(toEscape[:idx], needsEscape) {
|
||||
// We'll assume that they intend ~ expansion to occur
|
||||
start = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Now for simplicity we'll look at the rest of the string
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) {
|
||||
return toEscape
|
||||
}
|
||||
|
||||
// OK we have to do some escaping
|
||||
sb := &strings.Builder{}
|
||||
_, _ = sb.WriteString(toEscape[:start])
|
||||
|
||||
// Do we have any characters which absolutely need to be within single quotes - that is simply ! or \n?
|
||||
if strings.ContainsAny(toEscape[start:], needsSingleQuote) {
|
||||
// We need to single quote escape.
|
||||
sb2 := &strings.Builder{}
|
||||
_, _ = sb2.WriteRune('\'')
|
||||
_, _ = singleQuoteEscaper.WriteString(sb2, toEscape[start:])
|
||||
_, _ = sb2.WriteRune('\'')
|
||||
_, _ = singleQuoteCoalescer.WriteString(sb, sb2.String())
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// OK we can just use " just escape the things that need escaping
|
||||
_, _ = sb.WriteRune('"')
|
||||
_, _ = doubleQuoteEscaper.WriteString(sb, toEscape[start:])
|
||||
_, _ = sb.WriteRune('"')
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShellEscape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toEscape string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"Simplest case - nothing to escape",
|
||||
"a/b/c/d",
|
||||
"a/b/c/d",
|
||||
}, {
|
||||
"Prefixed tilde - with normal stuff - should not escape",
|
||||
"~/src/go/gitea/gitea",
|
||||
"~/src/go/gitea/gitea",
|
||||
}, {
|
||||
"Typical windows path with spaces - should get doublequote escaped",
|
||||
`C:\Program Files\Gitea v1.13 - I like lots of spaces\gitea`,
|
||||
`"C:\\Program Files\\Gitea v1.13 - I like lots of spaces\\gitea"`,
|
||||
}, {
|
||||
"Forward-slashed windows path with spaces - should get doublequote escaped",
|
||||
"C:/Program Files/Gitea v1.13 - I like lots of spaces/gitea",
|
||||
`"C:/Program Files/Gitea v1.13 - I like lots of spaces/gitea"`,
|
||||
}, {
|
||||
"Prefixed tilde - but then a space filled path",
|
||||
"~git/Gitea v1.13/gitea",
|
||||
`~git/"Gitea v1.13/gitea"`,
|
||||
}, {
|
||||
"Bangs are unfortunately not predictable so need to be singlequoted",
|
||||
"C:/Program Files/Gitea!/gitea",
|
||||
`'C:/Program Files/Gitea!/gitea'`,
|
||||
}, {
|
||||
"Newlines are just irritating",
|
||||
"/home/git/Gitea\n\nWHY-WOULD-YOU-DO-THIS\n\nGitea/gitea",
|
||||
"'/home/git/Gitea\n\nWHY-WOULD-YOU-DO-THIS\n\nGitea/gitea'",
|
||||
}, {
|
||||
"Similarly we should nicely handle multiple single quotes if we have to single-quote",
|
||||
"'!''!'''!''!'!'",
|
||||
`\''!'\'\''!'\'\'\''!'\'\''!'\''!'\'`,
|
||||
}, {
|
||||
"Double quote < ...",
|
||||
"~/<gitea",
|
||||
"~/\"<gitea\"",
|
||||
}, {
|
||||
"Double quote > ...",
|
||||
"~/gitea>",
|
||||
"~/\"gitea>\"",
|
||||
}, {
|
||||
"Double quote and escape $ ...",
|
||||
"~/$gitea",
|
||||
"~/\"\\$gitea\"",
|
||||
}, {
|
||||
"Double quote {...",
|
||||
"~/{gitea",
|
||||
"~/\"{gitea\"",
|
||||
}, {
|
||||
"Double quote }...",
|
||||
"~/gitea}",
|
||||
"~/\"gitea}\"",
|
||||
}, {
|
||||
"Double quote ()...",
|
||||
"~/(gitea)",
|
||||
"~/\"(gitea)\"",
|
||||
}, {
|
||||
"Double quote and escape `...",
|
||||
"~/gitea`",
|
||||
"~/\"gitea\\`\"",
|
||||
}, {
|
||||
"Double quotes can handle a number of things without having to escape them but not everything ...",
|
||||
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
|
||||
"~/\"<gitea> \\${gitea} \\`gitea\\` [gitea] (gitea) \\\"gitea\\\" \\\\gitea\\\\ 'gitea'\"",
|
||||
}, {
|
||||
"Single quotes don't need to escape except for '...",
|
||||
"~/<gitea> ${gitea} `gitea` (gitea) !gitea! \"gitea\" \\gitea\\ 'gitea'",
|
||||
"~/'<gitea> ${gitea} `gitea` (gitea) !gitea! \"gitea\" \\gitea\\ '\\''gitea'\\'",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShellEscape(tt.toEscape))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SliceContainsString sequential searches if string exists in slice.
|
||||
func SliceContainsString(slice []string, target string, insensitive ...bool) bool {
|
||||
if len(insensitive) != 0 && insensitive[0] {
|
||||
return slices.ContainsFunc(slice, func(t string) bool { return strings.EqualFold(t, target) })
|
||||
}
|
||||
|
||||
return slices.Contains(slice, target)
|
||||
}
|
||||
|
||||
// SliceSortedEqual returns true if the two slices will be equal when they get sorted.
|
||||
// It doesn't require that the slices have been sorted, and it doesn't sort them either.
|
||||
func SliceSortedEqual[T comparable](s1, s2 []T) bool {
|
||||
if len(s1) != len(s2) {
|
||||
return false
|
||||
}
|
||||
|
||||
counts := make(map[T]int, len(s1))
|
||||
for _, v := range s1 {
|
||||
counts[v]++
|
||||
}
|
||||
for _, v := range s2 {
|
||||
counts[v]--
|
||||
}
|
||||
|
||||
for _, v := range counts {
|
||||
if v != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SliceRemoveAll removes all the target elements from the slice.
|
||||
func SliceRemoveAll[T comparable](slice []T, target T) []T {
|
||||
return slices.DeleteFunc(slice, func(t T) bool { return t == target })
|
||||
}
|
||||
|
||||
// Sorted returns the sorted slice
|
||||
// Note: The parameter is sorted inline.
|
||||
func Sorted[S ~[]E, E cmp.Ordered](values S) S {
|
||||
slices.Sort(values)
|
||||
return values
|
||||
}
|
||||
|
||||
// TODO: Replace with "maps.Values" once available, current it only in golang.org/x/exp/maps but not in standard library
|
||||
func ValuesOfMap[K comparable, V any](m map[K]V) []V {
|
||||
values := make([]V, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// TODO: Replace with "maps.Keys" once available, current it only in golang.org/x/exp/maps but not in standard library
|
||||
func KeysOfMap[K comparable, V any](m map[K]V) []K {
|
||||
keys := make([]K, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func SliceNilAsEmpty[T any](a []T) []T {
|
||||
if a == nil {
|
||||
return []T{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSliceContainsString(t *testing.T) {
|
||||
assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "a"))
|
||||
assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "b"))
|
||||
assert.True(t, SliceContainsString([]string{"c", "b", "a", "b"}, "A", true))
|
||||
assert.True(t, SliceContainsString([]string{"C", "B", "A", "B"}, "a", true))
|
||||
|
||||
assert.False(t, SliceContainsString([]string{"c", "b", "a", "b"}, "z"))
|
||||
assert.False(t, SliceContainsString([]string{"c", "b", "a", "b"}, "A"))
|
||||
assert.False(t, SliceContainsString([]string{}, "a"))
|
||||
assert.False(t, SliceContainsString(nil, "a"))
|
||||
}
|
||||
|
||||
func TestSliceSortedEqual(t *testing.T) {
|
||||
assert.True(t, SliceSortedEqual([]int{2, 0, 2, 3}, []int{2, 0, 2, 3}))
|
||||
assert.True(t, SliceSortedEqual([]int{3, 0, 2, 2}, []int{2, 0, 2, 3}))
|
||||
assert.True(t, SliceSortedEqual([]int{}, []int{}))
|
||||
assert.True(t, SliceSortedEqual([]int(nil), nil))
|
||||
assert.True(t, SliceSortedEqual([]int(nil), []int{}))
|
||||
assert.True(t, SliceSortedEqual([]int{}, []int{}))
|
||||
|
||||
assert.True(t, SliceSortedEqual([]string{"2", "0", "2", "3"}, []string{"2", "0", "2", "3"}))
|
||||
assert.True(t, SliceSortedEqual([]float64{2, 0, 2, 3}, []float64{2, 0, 2, 3}))
|
||||
assert.True(t, SliceSortedEqual([]bool{false, true, false}, []bool{false, true, false}))
|
||||
|
||||
assert.False(t, SliceSortedEqual([]int{2, 0, 2}, []int{2, 0, 2, 3}))
|
||||
assert.False(t, SliceSortedEqual([]int{}, []int{2, 0, 2, 3}))
|
||||
assert.False(t, SliceSortedEqual(nil, []int{2, 0, 2, 3}))
|
||||
assert.False(t, SliceSortedEqual([]int{2, 0, 2, 4}, []int{2, 0, 2, 3}))
|
||||
assert.False(t, SliceSortedEqual([]int{2, 0, 0, 3}, []int{2, 0, 2, 3}))
|
||||
}
|
||||
|
||||
func TestSliceRemoveAll(t *testing.T) {
|
||||
assert.ElementsMatch(t, []int{2, 2, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 0))
|
||||
assert.ElementsMatch(t, []int{0, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 2))
|
||||
assert.Empty(t, SliceRemoveAll([]int{0, 0, 0, 0}, 0))
|
||||
assert.ElementsMatch(t, []int{2, 0, 2, 3}, SliceRemoveAll([]int{2, 0, 2, 3}, 4))
|
||||
assert.Empty(t, SliceRemoveAll([]int{}, 0))
|
||||
assert.ElementsMatch(t, []int(nil), SliceRemoveAll([]int(nil), 0))
|
||||
assert.Empty(t, SliceRemoveAll([]int{}, 0))
|
||||
|
||||
assert.ElementsMatch(t, []string{"2", "2", "3"}, SliceRemoveAll([]string{"2", "0", "2", "3"}, "0"))
|
||||
assert.ElementsMatch(t, []float64{2, 2, 3}, SliceRemoveAll([]float64{2, 0, 2, 3}, 0))
|
||||
assert.ElementsMatch(t, []bool{false, false}, SliceRemoveAll([]bool{false, true, false}, true))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func isSnakeCaseUpper(c byte) bool {
|
||||
return 'A' <= c && c <= 'Z'
|
||||
}
|
||||
|
||||
func isSnakeCaseLowerOrNumber(c byte) bool {
|
||||
return 'a' <= c && c <= 'z' || '0' <= c && c <= '9'
|
||||
}
|
||||
|
||||
// ToSnakeCase convert the input string to snake_case format.
|
||||
//
|
||||
// Some samples.
|
||||
//
|
||||
// "FirstName" => "first_name"
|
||||
// "HTTPServer" => "http_server"
|
||||
// "NoHTTPS" => "no_https"
|
||||
// "GO_PATH" => "go_path"
|
||||
// "GO PATH" => "go_path" // space is converted to underscore.
|
||||
// "GO-PATH" => "go_path" // hyphen is converted to underscore.
|
||||
func ToSnakeCase(input string) string {
|
||||
if len(input) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var res []byte
|
||||
if len(input) == 1 {
|
||||
c := input[0]
|
||||
if isSnakeCaseUpper(c) {
|
||||
res = []byte{c + 'a' - 'A'}
|
||||
} else if isSnakeCaseLowerOrNumber(c) {
|
||||
res = []byte{c}
|
||||
} else {
|
||||
res = []byte{'_'}
|
||||
}
|
||||
} else {
|
||||
res = make([]byte, 0, len(input)*4/3)
|
||||
pos := 0
|
||||
needSep := false
|
||||
for pos < len(input) {
|
||||
c := input[pos]
|
||||
if c >= 0x80 {
|
||||
res = append(res, c)
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
isUpper := isSnakeCaseUpper(c)
|
||||
if isUpper || isSnakeCaseLowerOrNumber(c) {
|
||||
end := pos + 1
|
||||
if isUpper {
|
||||
// skip the following upper letters
|
||||
for end < len(input) && isSnakeCaseUpper(input[end]) {
|
||||
end++
|
||||
}
|
||||
if end-pos > 1 && end < len(input) && isSnakeCaseLowerOrNumber(input[end]) {
|
||||
end--
|
||||
}
|
||||
}
|
||||
// skip the following lower or number letters
|
||||
for end < len(input) && (isSnakeCaseLowerOrNumber(input[end]) || input[end] >= 0x80) {
|
||||
end++
|
||||
}
|
||||
if needSep {
|
||||
res = append(res, '_')
|
||||
}
|
||||
res = append(res, input[pos:end]...)
|
||||
pos = end
|
||||
needSep = true
|
||||
} else {
|
||||
res = append(res, '_')
|
||||
pos++
|
||||
needSep = false
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(res); i++ {
|
||||
if isSnakeCaseUpper(res[i]) {
|
||||
res[i] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
}
|
||||
return UnsafeBytesToString(res)
|
||||
}
|
||||
|
||||
// UnsafeBytesToString uses Go's unsafe package to convert a byte slice to a string.
|
||||
func UnsafeBytesToString(b []byte) string {
|
||||
return unsafe.String(unsafe.SliceData(b), len(b))
|
||||
}
|
||||
|
||||
// UnsafeStringToBytes uses Go's unsafe package to convert a string to a byte slice.
|
||||
func UnsafeStringToBytes(s string) []byte {
|
||||
return unsafe.Slice(unsafe.StringData(s), len(s))
|
||||
}
|
||||
|
||||
// SplitTrimSpace splits the string at given separator and trims leading and trailing space
|
||||
func SplitTrimSpace(input, sep string) []string {
|
||||
input = strings.TrimSpace(input)
|
||||
var stringList []string
|
||||
for s := range strings.SplitSeq(input, sep) {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
stringList = append(stringList, s)
|
||||
}
|
||||
}
|
||||
return stringList
|
||||
}
|
||||
|
||||
func asciiLower(b byte) byte {
|
||||
if 'A' <= b && b <= 'Z' {
|
||||
return b + ('a' - 'A')
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go
|
||||
// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold]
|
||||
func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase
|
||||
if len(s) != len(t) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if asciiLower(s[i]) != asciiLower(t[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestToSnakeCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// all old cases from the legacy package
|
||||
"HTTPServer": "http_server",
|
||||
"_camelCase": "_camel_case",
|
||||
"NoHTTPS": "no_https",
|
||||
"Wi_thF": "wi_th_f",
|
||||
"_AnotherTES_TCaseP": "_another_tes_t_case_p",
|
||||
"ALL": "all",
|
||||
"_HELLO_WORLD_": "_hello_world_",
|
||||
"HELLO_WORLD": "hello_world",
|
||||
"HELLO____WORLD": "hello____world",
|
||||
"TW": "tw",
|
||||
"_C": "_c",
|
||||
|
||||
" sentence case ": "__sentence_case__",
|
||||
" Mixed-hyphen case _and SENTENCE_case and UPPER-case": "_mixed_hyphen_case__and_sentence_case_and_upper_case",
|
||||
|
||||
// new cases
|
||||
" ": "_",
|
||||
"A": "a",
|
||||
"A0": "a0",
|
||||
"a0": "a0",
|
||||
"Aa0": "aa0",
|
||||
"啊": "啊",
|
||||
"A啊": "a啊",
|
||||
"Aa啊b": "aa啊b",
|
||||
"A啊B": "a啊_b",
|
||||
"Aa啊B": "aa啊_b",
|
||||
"TheCase2": "the_case2",
|
||||
"ObjIDs": "obj_i_ds", // the strange database column name which already exists
|
||||
}
|
||||
for input, expected := range cases {
|
||||
assert.Equal(t, expected, ToSnakeCase(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTrimSpace(t *testing.T) {
|
||||
assert.Equal(t, []string{"a", "b", "c"}, SplitTrimSpace("a\nb\nc", "\n"))
|
||||
assert.Equal(t, []string{"a", "b"}, SplitTrimSpace("\r\na\n\r\nb\n\n", "\n"))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2024 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type timeStrGlobalVarsType struct {
|
||||
units []struct {
|
||||
name string
|
||||
num int64
|
||||
}
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
// When tracking working time, only hour/minute/second units are accurate and could be used.
|
||||
// For other units like "day", it depends on "how many working hours in a day": 6 or 7 or 8?
|
||||
// So at the moment, we only support hour/minute/second units.
|
||||
// In the future, it could be some configurable options to help users
|
||||
// to convert the working time to different units.
|
||||
|
||||
var timeStrGlobalVars = sync.OnceValue(func() *timeStrGlobalVarsType {
|
||||
v := &timeStrGlobalVarsType{}
|
||||
v.re = regexp.MustCompile(`(?i)(\d+)\s*([hms])`)
|
||||
v.units = []struct {
|
||||
name string
|
||||
num int64
|
||||
}{
|
||||
{"h", 60 * 60},
|
||||
{"m", 60},
|
||||
{"s", 1},
|
||||
}
|
||||
return v
|
||||
})
|
||||
|
||||
func TimeEstimateParse(timeStr string) (int64, error) {
|
||||
if timeStr == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int64
|
||||
matches := timeStrGlobalVars().re.FindAllStringSubmatchIndex(timeStr, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0, fmt.Errorf("invalid time string: %s", timeStr)
|
||||
}
|
||||
if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) {
|
||||
return 0, fmt.Errorf("invalid time string: %s", timeStr)
|
||||
}
|
||||
for _, match := range matches {
|
||||
amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid time string: %v", err)
|
||||
}
|
||||
unit := timeStr[match[4]:match[5]]
|
||||
found := false
|
||||
for _, u := range timeStrGlobalVars().units {
|
||||
if strings.EqualFold(unit, u.name) {
|
||||
total += amount * u.num
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 0, fmt.Errorf("invalid time unit: %s", unit)
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func TimeEstimateString(amount int64) string {
|
||||
var timeParts []string
|
||||
for _, u := range timeStrGlobalVars().units {
|
||||
if amount >= u.num {
|
||||
num := amount / u.num
|
||||
amount %= u.num
|
||||
timeParts = append(timeParts, fmt.Sprintf("%d%s", num, u.name))
|
||||
}
|
||||
}
|
||||
return strings.Join(timeParts, " ")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2024 Gitea. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTimeStr(t *testing.T) {
|
||||
t.Run("Parse", func(t *testing.T) {
|
||||
// Test TimeEstimateParse
|
||||
tests := []struct {
|
||||
input string
|
||||
output int64
|
||||
err bool
|
||||
}{
|
||||
{"1h", 3600, false},
|
||||
{"1m", 60, false},
|
||||
{"1s", 1, false},
|
||||
{"1h 1m 1s", 3600 + 60 + 1, false},
|
||||
{"1d1x", 0, true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
output, err := TimeEstimateParse(test.input)
|
||||
if test.err {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, test.output, output)
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("String", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int64
|
||||
output string
|
||||
}{
|
||||
{3600, "1h"},
|
||||
{60, "1m"},
|
||||
{1, "1s"},
|
||||
{3600 + 1, "1h 1s"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.output, func(t *testing.T) {
|
||||
output := TimeEstimateString(test.input)
|
||||
assert.Equal(t, test.output, output)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Debounce(d time.Duration) func(f func()) {
|
||||
type debouncer struct {
|
||||
mu sync.Mutex
|
||||
t *time.Timer
|
||||
}
|
||||
db := &debouncer{}
|
||||
|
||||
return func(f func()) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
|
||||
if db.t != nil {
|
||||
db.t.Stop()
|
||||
}
|
||||
var trigger *time.Timer
|
||||
trigger = time.AfterFunc(d, func() {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
if trigger == db.t {
|
||||
f()
|
||||
db.t = nil
|
||||
}
|
||||
})
|
||||
db.t = trigger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDebounce(t *testing.T) {
|
||||
var c atomic.Int64
|
||||
d := Debounce(50 * time.Millisecond)
|
||||
d(func() { c.Add(1) })
|
||||
assert.EqualValues(t, 0, c.Load())
|
||||
d(func() { c.Add(1) })
|
||||
d(func() { c.Add(1) })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assert.EqualValues(t, 1, c.Load())
|
||||
d(func() { c.Add(1) })
|
||||
assert.EqualValues(t, 1, c.Load())
|
||||
d(func() { c.Add(1) })
|
||||
d(func() { c.Add(1) })
|
||||
d(func() { c.Add(1) })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
assert.EqualValues(t, 2, c.Load())
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// in UTF8 "…" is 3 bytes so doesn't really gain us anything...
|
||||
const (
|
||||
utf8Ellipsis = "…"
|
||||
asciiEllipsis = "..."
|
||||
)
|
||||
|
||||
func IsLikelyEllipsisLeftPart(s string) bool {
|
||||
return strings.HasSuffix(s, utf8Ellipsis) || strings.HasSuffix(s, asciiEllipsis)
|
||||
}
|
||||
|
||||
func ellipsisDisplayGuessWidth(r rune) int {
|
||||
// To make the truncated string as long as possible,
|
||||
// CJK/emoji chars are considered as 2-ASCII width but not 3-4 bytes width.
|
||||
// Here we only make the best guess (better than counting them in bytes),
|
||||
// it's impossible to 100% correctly determine the width of a rune without a real font and render.
|
||||
//
|
||||
// ATTENTION: the guessed width can't be zero, more details in ellipsisDisplayString's comment
|
||||
if r <= 255 {
|
||||
return 1
|
||||
}
|
||||
|
||||
switch {
|
||||
case r == '\u3000': /* ideographic (CJK) characters, still use 2 */
|
||||
return 2
|
||||
case unicode.Is(unicode.M, r), /* (Mark) */
|
||||
unicode.Is(unicode.Cf, r), /* (Other, format) */
|
||||
unicode.Is(unicode.Cs, r), /* (Other, surrogate) */
|
||||
unicode.Is(unicode.Z /* (Space) */, r):
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// EllipsisDisplayString returns a truncated short string for display purpose.
|
||||
// The length is the approximate number of ASCII-width in the string (CJK/emoji are 2-ASCII width)
|
||||
// It appends "…" or "..." at the end of truncated string.
|
||||
// It guarantees the length of the returned runes doesn't exceed the limit.
|
||||
func EllipsisDisplayString(str string, limit int) string {
|
||||
s, _, _, _ := ellipsisDisplayString(str, limit, ellipsisDisplayGuessWidth)
|
||||
return s
|
||||
}
|
||||
|
||||
// EllipsisDisplayStringX works like EllipsisDisplayString while it also returns the right part
|
||||
func EllipsisDisplayStringX(str string, limit int) (left, right string) {
|
||||
return ellipsisDisplayStringX(str, limit, ellipsisDisplayGuessWidth)
|
||||
}
|
||||
|
||||
func ellipsisDisplayStringX(str string, limit int, widthGuess func(rune) int) (left, right string) {
|
||||
left, offset, truncated, encounterInvalid := ellipsisDisplayString(str, limit, widthGuess)
|
||||
if truncated {
|
||||
right = str[offset:]
|
||||
r, _ := utf8.DecodeRune(UnsafeStringToBytes(right))
|
||||
encounterInvalid = encounterInvalid || r == utf8.RuneError
|
||||
ellipsis := utf8Ellipsis
|
||||
if encounterInvalid {
|
||||
ellipsis = asciiEllipsis
|
||||
}
|
||||
right = ellipsis + right
|
||||
}
|
||||
return left, right
|
||||
}
|
||||
|
||||
func ellipsisDisplayString(str string, limit int, widthGuess func(rune) int) (res string, offset int, truncated, encounterInvalid bool) {
|
||||
if len(str) <= limit {
|
||||
return str, len(str), false, false
|
||||
}
|
||||
|
||||
// To future maintainers: this logic must guarantee that the length of the returned runes doesn't exceed the limit,
|
||||
// because the returned string will also be used as database value. UTF-8 VARCHAR(10) could store 10 rune characters,
|
||||
// So each rune must be countered as at least 1 width.
|
||||
// Even if there are some special Unicode characters (zero-width, combining, etc.), they should NEVER be counted as zero.
|
||||
pos, used := 0, 0
|
||||
for i, r := range str {
|
||||
encounterInvalid = encounterInvalid || r == utf8.RuneError
|
||||
pos = i
|
||||
runeWidth := widthGuess(r)
|
||||
if used+runeWidth+3 > limit {
|
||||
break
|
||||
}
|
||||
used += runeWidth
|
||||
offset += utf8.RuneLen(r)
|
||||
}
|
||||
|
||||
// if the remaining are fewer than 3 runes, then maybe we could add them, no need to ellipse
|
||||
if len(str)-pos <= 12 {
|
||||
var nextCnt, nextWidth int
|
||||
for _, r := range str[pos:] {
|
||||
if nextCnt >= 4 {
|
||||
break
|
||||
}
|
||||
nextWidth += widthGuess(r)
|
||||
nextCnt++
|
||||
}
|
||||
if nextCnt <= 3 && used+nextWidth <= limit {
|
||||
return str, len(str), false, false
|
||||
}
|
||||
}
|
||||
if limit < 3 {
|
||||
// if the limit is so small, do not add ellipsis
|
||||
return str[:offset], offset, true, false
|
||||
}
|
||||
ellipsis := utf8Ellipsis
|
||||
if encounterInvalid {
|
||||
ellipsis = asciiEllipsis
|
||||
}
|
||||
return str[:offset] + ellipsis, offset, true, encounterInvalid
|
||||
}
|
||||
|
||||
func EllipsisTruncateRunes(str string, limit int) (left, right string) {
|
||||
return ellipsisDisplayStringX(str, limit, func(r rune) int { return 1 })
|
||||
}
|
||||
|
||||
// TruncateRunes returns a truncated string with given rune limit,
|
||||
// it returns input string if its rune length doesn't exceed the limit.
|
||||
func TruncateRunes(str string, limit int) string {
|
||||
if utf8.RuneCountInString(str) < limit {
|
||||
return str
|
||||
}
|
||||
return string([]rune(str)[:limit])
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEllipsisGuessDisplayWidth(t *testing.T) {
|
||||
cases := []struct {
|
||||
r string
|
||||
want int
|
||||
}{
|
||||
{r: "a", want: 1},
|
||||
{r: "é", want: 1},
|
||||
{r: "测", want: 2},
|
||||
{r: "⚽", want: 2},
|
||||
{r: "☁️", want: 3}, // 2 runes, it has a mark
|
||||
{r: "\u200B", want: 1}, // ZWSP
|
||||
{r: "\u3000", want: 2}, // ideographic space
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.r, func(t *testing.T) {
|
||||
w := 0
|
||||
for _, r := range c.r {
|
||||
w += ellipsisDisplayGuessWidth(r)
|
||||
}
|
||||
assert.Equal(t, c.want, w, "hex=% x", []byte(c.r))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEllipsisString(t *testing.T) {
|
||||
cases := []struct {
|
||||
limit int
|
||||
|
||||
input, left, right string
|
||||
}{
|
||||
{limit: 0, input: "abcde", left: "", right: "…abcde"},
|
||||
{limit: 1, input: "abcde", left: "", right: "…abcde"},
|
||||
{limit: 2, input: "abcde", left: "", right: "…abcde"},
|
||||
{limit: 3, input: "abcde", left: "…", right: "…abcde"},
|
||||
{limit: 4, input: "abcde", left: "a…", right: "…bcde"},
|
||||
{limit: 5, input: "abcde", left: "abcde", right: ""},
|
||||
{limit: 6, input: "abcde", left: "abcde", right: ""},
|
||||
{limit: 7, input: "abcde", left: "abcde", right: ""},
|
||||
|
||||
// a CJK char or emoji is considered as 2-ASCII width, the ellipsis is 3-ASCII width
|
||||
{limit: 0, input: "测试文本", left: "", right: "…测试文本"},
|
||||
{limit: 1, input: "测试文本", left: "", right: "…测试文本"},
|
||||
{limit: 2, input: "测试文本", left: "", right: "…测试文本"},
|
||||
{limit: 3, input: "测试文本", left: "…", right: "…测试文本"},
|
||||
{limit: 4, input: "测试文本", left: "…", right: "…测试文本"},
|
||||
{limit: 5, input: "测试文本", left: "测…", right: "…试文本"},
|
||||
{limit: 6, input: "测试文本", left: "测…", right: "…试文本"},
|
||||
{limit: 7, input: "测试文本", left: "测试…", right: "…文本"},
|
||||
{limit: 8, input: "测试文本", left: "测试文本", right: ""},
|
||||
{limit: 9, input: "测试文本", left: "测试文本", right: ""},
|
||||
|
||||
{limit: 6, input: "测试abc", left: "测…", right: "…试abc"},
|
||||
{limit: 7, input: "测试abc", left: "测试abc", right: ""}, // exactly 7-width
|
||||
{limit: 8, input: "测试abc", left: "测试abc", right: ""},
|
||||
|
||||
{limit: 7, input: "测abc试啊", left: "测ab…", right: "…c试啊"},
|
||||
{limit: 8, input: "测abc试啊", left: "测abc…", right: "…试啊"},
|
||||
{limit: 9, input: "测abc试啊", left: "测abc试啊", right: ""}, // exactly 9-width
|
||||
{limit: 10, input: "测abc试啊", left: "测abc试啊", right: ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(fmt.Sprintf("%s(%d)", c.input, c.limit), func(t *testing.T) {
|
||||
left, right := EllipsisDisplayStringX(c.input, c.limit)
|
||||
assert.Equal(t, c.left, left, "left")
|
||||
assert.Equal(t, c.right, right, "right")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("LongInput", func(t *testing.T) {
|
||||
left, right := EllipsisDisplayStringX(strings.Repeat("abc", 240), 90)
|
||||
assert.Equal(t, strings.Repeat("abc", 29)+"…", left)
|
||||
assert.Equal(t, "…"+strings.Repeat("abc", 211), right)
|
||||
})
|
||||
|
||||
t.Run("InvalidUtf8", func(t *testing.T) {
|
||||
invalidCases := []struct {
|
||||
limit int
|
||||
left, right string
|
||||
}{
|
||||
{limit: 0, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
|
||||
{limit: 1, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
|
||||
{limit: 2, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"},
|
||||
{limit: 3, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"},
|
||||
{limit: 4, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"},
|
||||
{limit: 5, left: "\xef\x03\xfe...", right: "...\xef\x03\xfe"},
|
||||
{limit: 6, left: "\xef\x03\xfe\xef\x03\xfe", right: ""},
|
||||
{limit: 7, left: "\xef\x03\xfe\xef\x03\xfe", right: ""},
|
||||
}
|
||||
for _, c := range invalidCases {
|
||||
t.Run(strconv.Itoa(c.limit), func(t *testing.T) {
|
||||
left, right := EllipsisDisplayStringX("\xef\x03\xfe\xef\x03\xfe", c.limit)
|
||||
assert.Equal(t, c.left, left, "left")
|
||||
assert.Equal(t, c.right, right, "right")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsLikelyEllipsisLeftPart", func(t *testing.T) {
|
||||
assert.True(t, IsLikelyEllipsisLeftPart("abcde…"))
|
||||
assert.True(t, IsLikelyEllipsisLeftPart("abcde..."))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
assert.Empty(t, TruncateRunes("", 0))
|
||||
assert.Empty(t, TruncateRunes("", 1))
|
||||
|
||||
assert.Empty(t, TruncateRunes("ab", 0))
|
||||
assert.Equal(t, "a", TruncateRunes("ab", 1))
|
||||
assert.Equal(t, "ab", TruncateRunes("ab", 2))
|
||||
assert.Equal(t, "ab", TruncateRunes("ab", 3))
|
||||
|
||||
assert.Empty(t, TruncateRunes("测试", 0))
|
||||
assert.Equal(t, "测", TruncateRunes("测试", 1))
|
||||
assert.Equal(t, "测试", TruncateRunes("测试", 2))
|
||||
assert.Equal(t, "测试", TruncateRunes("测试", 3))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PathEscapeSegments escapes segments of a path while not escaping forward slash
|
||||
func PathEscapeSegments(path string) string {
|
||||
slice := strings.Split(path, "/")
|
||||
for index := range slice {
|
||||
slice[index] = url.PathEscape(slice[index])
|
||||
}
|
||||
escapedPath := strings.Join(slice, "/")
|
||||
return escapedPath
|
||||
}
|
||||
|
||||
func SanitizeURL(s string) (string, error) {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.User = nil
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
rand2 "math/rand/v2"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// IsEmptyString checks if the provided string is empty
|
||||
func IsEmptyString(s string) bool {
|
||||
return len(strings.TrimSpace(s)) == 0
|
||||
}
|
||||
|
||||
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
|
||||
func NormalizeEOL(input []byte) []byte {
|
||||
var right, left, pos int
|
||||
if right = bytes.IndexByte(input, '\r'); right == -1 {
|
||||
return input
|
||||
}
|
||||
length := len(input)
|
||||
tmp := make([]byte, length)
|
||||
|
||||
// We know that left < length because otherwise right would be -1 from IndexByte.
|
||||
copy(tmp[pos:pos+right], input[left:left+right])
|
||||
pos += right
|
||||
tmp[pos] = '\n'
|
||||
left += right + 1
|
||||
pos++
|
||||
|
||||
for left < length {
|
||||
if input[left] == '\n' {
|
||||
left++
|
||||
}
|
||||
|
||||
right = bytes.IndexByte(input[left:], '\r')
|
||||
if right == -1 {
|
||||
copy(tmp[pos:], input[left:])
|
||||
pos += length - left
|
||||
break
|
||||
}
|
||||
copy(tmp[pos:pos+right], input[left:left+right])
|
||||
pos += right
|
||||
tmp[pos] = '\n'
|
||||
left += right + 1
|
||||
pos++
|
||||
}
|
||||
return tmp[:pos]
|
||||
}
|
||||
|
||||
// CryptoRandomInt returns a crypto random integer between 0 and limit, inclusive
|
||||
func CryptoRandomInt(limit int64) int64 {
|
||||
rInt, err := rand.Int(rand.Reader, big.NewInt(limit))
|
||||
if err != nil {
|
||||
panic(err) // this should never happen
|
||||
}
|
||||
return rInt.Int64()
|
||||
}
|
||||
|
||||
// CryptoRandomString generates a crypto random alphanumerical string, each byte is generated by [0,61] range
|
||||
func CryptoRandomString(length int64) string {
|
||||
const alphanumericalChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
buf := make([]byte, length)
|
||||
limit := int64(len(alphanumericalChars))
|
||||
for i := range buf {
|
||||
num := CryptoRandomInt(limit)
|
||||
buf[i] = alphanumericalChars[num]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// CryptoRandomBytes generates `length` crypto bytes
|
||||
// This differs from CryptoRandomString, as each byte in CryptoRandomString is generated by [0,61] range
|
||||
// This function generates totally random bytes, each byte is generated by [0,255] range
|
||||
func CryptoRandomBytes(length int64) []byte {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic(err) // this should never happen, "rand.Read" never fails
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
var chaCha8RandPool = sync.OnceValue(func() *sync.Pool {
|
||||
return &sync.Pool{
|
||||
New: func() any {
|
||||
seed := CryptoRandomBytes(32)
|
||||
return rand2.NewChaCha8([32]byte(seed))
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
func FastCryptoRandomBytes(length int) []byte {
|
||||
// ChaCha8 is about 20x times faster than system's crypto/rand.
|
||||
// It is suitable for UUIDs, session IDs, etc
|
||||
pool := chaCha8RandPool()
|
||||
chaCha8Rand := pool.Get().(*rand2.ChaCha8)
|
||||
defer pool.Put(chaCha8Rand)
|
||||
buf := make([]byte, length)
|
||||
_, _ = chaCha8Rand.Read(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func FastCryptoRandomHex(length int) string {
|
||||
buf := FastCryptoRandomBytes(length / 2)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
|
||||
func ToLowerASCII(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if 'A' <= c && c <= 'Z' {
|
||||
b[i] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ToTitleCase returns s with all english words capitalized
|
||||
func ToTitleCase(s string) string {
|
||||
// `cases.Title` is not thread-safe, do not use global shared variable for it
|
||||
return cases.Title(language.English).String(s)
|
||||
}
|
||||
|
||||
// ToTitleCaseNoLower returns s with all english words capitalized without lower-casing
|
||||
func ToTitleCaseNoLower(s string) string {
|
||||
// `cases.Title` is not thread-safe, do not use global shared variable for it
|
||||
return cases.Title(language.English, cases.NoLower).String(s)
|
||||
}
|
||||
|
||||
// ToInt64 transform a given int into int64.
|
||||
func ToInt64(number any) (int64, error) {
|
||||
var value int64
|
||||
switch v := number.(type) {
|
||||
case int:
|
||||
value = int64(v)
|
||||
case int8:
|
||||
value = int64(v)
|
||||
case int16:
|
||||
value = int64(v)
|
||||
case int32:
|
||||
value = int64(v)
|
||||
case int64:
|
||||
value = v
|
||||
|
||||
case uint:
|
||||
value = int64(v)
|
||||
case uint8:
|
||||
value = int64(v)
|
||||
case uint16:
|
||||
value = int64(v)
|
||||
case uint32:
|
||||
value = int64(v)
|
||||
case uint64:
|
||||
value = int64(v)
|
||||
|
||||
case float32:
|
||||
value = int64(v)
|
||||
case float64:
|
||||
value = int64(v)
|
||||
|
||||
case string:
|
||||
var err error
|
||||
if value, err = strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
default:
|
||||
return 0, fmt.Errorf("unable to convert %v to int64", number)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ToFloat64 transform a given int into float64.
|
||||
func ToFloat64(number any) (float64, error) {
|
||||
var value float64
|
||||
switch v := number.(type) {
|
||||
case int:
|
||||
value = float64(v)
|
||||
case int8:
|
||||
value = float64(v)
|
||||
case int16:
|
||||
value = float64(v)
|
||||
case int32:
|
||||
value = float64(v)
|
||||
case int64:
|
||||
value = float64(v)
|
||||
|
||||
case uint:
|
||||
value = float64(v)
|
||||
case uint8:
|
||||
value = float64(v)
|
||||
case uint16:
|
||||
value = float64(v)
|
||||
case uint32:
|
||||
value = float64(v)
|
||||
case uint64:
|
||||
value = float64(v)
|
||||
|
||||
case float32:
|
||||
value = float64(v)
|
||||
case float64:
|
||||
value = v
|
||||
|
||||
case string:
|
||||
var err error
|
||||
if value, err = strconv.ParseFloat(v, 64); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
default:
|
||||
return 0, fmt.Errorf("unable to convert %v to float64", number)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Iif is an "inline-if", it returns "trueVal" if "condition" is true, otherwise "falseVal"
|
||||
func Iif[T any](condition bool, trueVal, falseVal T) T {
|
||||
if condition {
|
||||
return trueVal
|
||||
}
|
||||
return falseVal
|
||||
}
|
||||
|
||||
// IfZero returns "def" if "v" is a zero value, otherwise "v"
|
||||
func IfZero[T comparable](v, def T) T {
|
||||
var zero T
|
||||
if v == zero {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func IfEmpty[T any](v, def []T) []T {
|
||||
if len(v) == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// OptionalArg helps the "optional argument" in Golang:
|
||||
//
|
||||
// func foo(optArg ...int) { return OptionalArg(optArg) }
|
||||
// calling `foo()` gets zero value 0, calling `foo(100)` gets 100
|
||||
// func bar(optArg ...int) { return OptionalArg(optArg, 42) }
|
||||
// calling `bar()` gets default value 42, calling `bar(100)` gets 100
|
||||
//
|
||||
// Passing more than 1 item to `optArg` or `defaultValue` is undefined behavior.
|
||||
// At the moment only the first item is used.
|
||||
func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) {
|
||||
if len(optArg) >= 1 {
|
||||
return optArg[0]
|
||||
}
|
||||
if len(defaultValue) >= 1 {
|
||||
return defaultValue[0]
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type EnumConst[T comparable] interface {
|
||||
EnumValues() []T
|
||||
}
|
||||
|
||||
// EnumValue returns the value if it's in the enum const's values,
|
||||
// otherwise returns the first item of enums as default value.
|
||||
func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
|
||||
enums := val.EnumValues()
|
||||
if slices.Contains(enums, val.(T)) {
|
||||
return val.(T), true
|
||||
}
|
||||
return enums[0], false
|
||||
}
|
||||
|
||||
func NormalizeStringEOL(input string) string {
|
||||
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
||||
// It's a standard behavior of HTML.
|
||||
// But in most cases, we only want "\n" for EOL
|
||||
// * Text files: use "\n" by default because "\r\n" sometimes doesn't work in POSIX
|
||||
// * Actions values: store them as "\n" like what GitHub does.
|
||||
// And users are unlikely to really need the "\r".
|
||||
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||
return UnsafeBytesToString(NormalizeEOL(UnsafeStringToBytes(input)))
|
||||
}
|
||||
|
||||
func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
|
||||
oldSet := container.SetOf(oldSlice...)
|
||||
newSet := container.SetOf(newSlice...)
|
||||
|
||||
addedSet, removedSet := container.Set[T]{}, container.Set[T]{}
|
||||
for _, v := range newSlice {
|
||||
if !oldSet.Contains(v) && addedSet.Add(v) {
|
||||
added = append(added, v)
|
||||
}
|
||||
}
|
||||
for _, v := range oldSlice {
|
||||
if !newSet.Contains(v) && removedSet.Add(v) {
|
||||
removed = append(removed, v)
|
||||
}
|
||||
}
|
||||
return added, removed
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsEmptyString(t *testing.T) {
|
||||
cases := []struct {
|
||||
s string
|
||||
expected bool
|
||||
}{
|
||||
{"", true},
|
||||
{" ", true},
|
||||
{" ", true},
|
||||
{" a", false},
|
||||
}
|
||||
|
||||
for _, v := range cases {
|
||||
assert.Equal(t, v.expected, IsEmptyString(v.s))
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NormalizeEOL(t *testing.T) {
|
||||
data1 := []string{
|
||||
"",
|
||||
"This text starts with empty lines",
|
||||
"another",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Some other empty lines in the middle",
|
||||
"more.",
|
||||
"And more.",
|
||||
"Ends with empty lines too.",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
}
|
||||
|
||||
data2 := []string{
|
||||
"This text does not start with empty lines",
|
||||
"another",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Some other empty lines in the middle",
|
||||
"more.",
|
||||
"And more.",
|
||||
"Ends without EOLtoo.",
|
||||
}
|
||||
|
||||
buildEOLData := func(data []string, eol string) []byte {
|
||||
return []byte(strings.Join(data, eol))
|
||||
}
|
||||
|
||||
dos := buildEOLData(data1, "\r\n")
|
||||
unix := buildEOLData(data1, "\n")
|
||||
mac := buildEOLData(data1, "\r")
|
||||
|
||||
assert.Equal(t, unix, NormalizeEOL(dos))
|
||||
assert.Equal(t, unix, NormalizeEOL(mac))
|
||||
assert.Equal(t, unix, NormalizeEOL(unix))
|
||||
|
||||
dos = buildEOLData(data2, "\r\n")
|
||||
unix = buildEOLData(data2, "\n")
|
||||
mac = buildEOLData(data2, "\r")
|
||||
|
||||
assert.Equal(t, unix, NormalizeEOL(dos))
|
||||
assert.Equal(t, unix, NormalizeEOL(mac))
|
||||
assert.Equal(t, unix, NormalizeEOL(unix))
|
||||
|
||||
assert.Equal(t, []byte("one liner"), NormalizeEOL([]byte("one liner")))
|
||||
assert.Equal(t, []byte("\n"), NormalizeEOL([]byte("\n")))
|
||||
assert.Equal(t, []byte("\ntwo liner"), NormalizeEOL([]byte("\ntwo liner")))
|
||||
assert.Equal(t, []byte("two liner\n"), NormalizeEOL([]byte("two liner\n")))
|
||||
assert.Equal(t, []byte{}, NormalizeEOL([]byte{}))
|
||||
|
||||
assert.Equal(t, []byte("mix\nand\nmatch\n."), NormalizeEOL([]byte("mix\r\nand\rmatch\n.")))
|
||||
}
|
||||
|
||||
func Test_RandomInt(t *testing.T) {
|
||||
randInt := CryptoRandomInt(255)
|
||||
assert.GreaterOrEqual(t, randInt, int64(0))
|
||||
assert.LessOrEqual(t, randInt, int64(255))
|
||||
}
|
||||
|
||||
func Test_RandomString(t *testing.T) {
|
||||
str1 := CryptoRandomString(32)
|
||||
var err error
|
||||
matches, err := regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, matches)
|
||||
|
||||
str2 := CryptoRandomString(32)
|
||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{32}$`, str1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, matches)
|
||||
|
||||
assert.NotEqual(t, str1, str2)
|
||||
|
||||
str3 := CryptoRandomString(256)
|
||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str3)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, matches)
|
||||
|
||||
str4 := CryptoRandomString(256)
|
||||
matches, err = regexp.MatchString(`^[a-zA-Z0-9]{256}$`, str4)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, matches)
|
||||
|
||||
assert.NotEqual(t, str3, str4)
|
||||
}
|
||||
|
||||
func Test_RandomBytes(t *testing.T) {
|
||||
bytes1 := CryptoRandomBytes(32)
|
||||
|
||||
bytes2 := CryptoRandomBytes(32)
|
||||
|
||||
assert.NotEqual(t, bytes1, bytes2)
|
||||
|
||||
bytes3 := CryptoRandomBytes(256)
|
||||
|
||||
bytes4 := CryptoRandomBytes(256)
|
||||
|
||||
assert.NotEqual(t, bytes3, bytes4)
|
||||
}
|
||||
|
||||
// Test case for any function which accepts and returns a single string.
|
||||
type StringTest struct {
|
||||
in, out string
|
||||
}
|
||||
|
||||
var lowerTests = []StringTest{
|
||||
{"", ""},
|
||||
{"ABC", "abc"},
|
||||
{"AbC123_", "abc123_"},
|
||||
{"LONG\u0250string\u0250WITH\u0250non-ascii\u2C6FCHARS\u0080\uFFFF", "long\u0250string\u0250with\u0250non-ascii\u2C6Fchars\u0080\uFFFF"},
|
||||
{"lél", "lél"},
|
||||
{"LÉL", "lÉl"},
|
||||
}
|
||||
|
||||
func TestToLowerASCII(t *testing.T) {
|
||||
for _, tc := range lowerTests {
|
||||
assert.Equal(t, ToLowerASCII(tc.in), tc.out)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToLower(b *testing.B) {
|
||||
for _, tc := range lowerTests {
|
||||
b.Run(tc.in, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
ToLowerASCII(tc.in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTitleCase(t *testing.T) {
|
||||
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`foo bar baz`))
|
||||
assert.Equal(t, `Foo Bar Baz`, ToTitleCase(`FOO BAR BAZ`))
|
||||
}
|
||||
|
||||
func TestNormalizeStringEOL(t *testing.T) {
|
||||
assert.Equal(t, "test\ndata", NormalizeStringEOL("test\r\ndata"))
|
||||
assert.Equal(t, " test\ndata\n ", NormalizeStringEOL(" test\rdata\r "))
|
||||
}
|
||||
|
||||
func TestOptionalArg(t *testing.T) {
|
||||
foo := func(_ any, optArg ...int) int {
|
||||
return OptionalArg(optArg)
|
||||
}
|
||||
bar := func(_ any, optArg ...int) int {
|
||||
return OptionalArg(optArg, 42)
|
||||
}
|
||||
assert.Equal(t, 0, foo(nil))
|
||||
assert.Equal(t, 100, foo(nil, 100))
|
||||
assert.Equal(t, 42, bar(nil))
|
||||
assert.Equal(t, 100, bar(nil, 100))
|
||||
}
|
||||
|
||||
func TestPathEscapeSegments(t *testing.T) {
|
||||
assert.Equal(t, "a", PathEscapeSegments("a"))
|
||||
assert.Equal(t, "a/b", PathEscapeSegments("a/b"))
|
||||
assert.Equal(t, "a/b%20c", PathEscapeSegments("a/b c"))
|
||||
assert.Equal(t, "a/b+c", PathEscapeSegments("a/b+c"))
|
||||
}
|
||||
Reference in New Issue
Block a user