Merge pull request #148 from mrd0ll4r/deniability-middleware
Deniability middleware, some util packages
This commit is contained in:
commit
83ffc0425e
10 changed files with 553 additions and 0 deletions
|
@ -21,6 +21,7 @@ import (
|
|||
_ "github.com/chihaya/chihaya/server/store/memory"
|
||||
_ "github.com/chihaya/chihaya/server/store/middleware/client"
|
||||
_ "github.com/chihaya/chihaya/server/store/middleware/ip"
|
||||
_ "github.com/chihaya/chihaya/middleware/deniability"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
|
39
middleware/deniability/README.md
Normal file
39
middleware/deniability/README.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
## Deniability Middleware
|
||||
|
||||
This package provides the announce middleware `deniability` which inserts ghost peers into announce responses to achieve plausible deniability.
|
||||
|
||||
### Functionality
|
||||
|
||||
This middleware will choose random announces and modify the list of peers returned.
|
||||
A random number of randomly generated peers will be inserted at random positions into the list of peers.
|
||||
As soon as the list of peers exceeds `numWant`, peers will be replaced rather than inserted.
|
||||
|
||||
Note that if a response is picked for augmentation, both IPv4 and IPv6 peers will be modified, in case they are not empty.
|
||||
|
||||
Also note that the IP address for the generated peeer consists of bytes in the range [1,254].
|
||||
|
||||
### Configuration
|
||||
|
||||
This middleware provides the following parameters for configuration:
|
||||
|
||||
- `modify_response_probability` (float, >0, <= 1) indicates the probability by which a response will be augmented with random peers.
|
||||
- `max_random_peers` (int, >0) sets an upper boundary (inclusive) for the amount of peers added.
|
||||
- `prefix` (string, 20 characters at most) sets the prefix for generated peer IDs.
|
||||
The peer ID will be padded to 20 bytes using a random string of alphanumeric characters.
|
||||
- `min_port` (int, >0, <=65535) sets a lower boundary for the port for generated peers.
|
||||
- `max_port` (int, >0, <=65536, > `min_port`) sets an upper boundary for the port for generated peers.
|
||||
|
||||
An example config might look like this:
|
||||
|
||||
chihaya:
|
||||
tracker:
|
||||
announce_middleware:
|
||||
- name: deniability
|
||||
config:
|
||||
modify_response_probability: 0.2
|
||||
max_random_peers: 5
|
||||
prefix: -AZ2060-
|
||||
min_port: 40000
|
||||
max_port: 60000
|
||||
|
||||
For more information about peer IDs and their prefixes, see [this wiki entry](https://wiki.theory.org/BitTorrentSpecification#peer_id).
|
46
middleware/deniability/config.go
Normal file
46
middleware/deniability/config.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package deniability
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/chihaya/chihaya"
|
||||
)
|
||||
|
||||
// Config represents the configuration for the deniability middleware.
|
||||
type Config struct {
|
||||
// ModifyResponseProbability is the probability by which a response will
|
||||
// be augmented with random peers.
|
||||
ModifyResponseProbability float32 `yaml:"modify_response_probability"`
|
||||
|
||||
// MaxRandomPeers is the amount of peers that will be added at most.
|
||||
MaxRandomPeers int `yaml:"max_random_peers"`
|
||||
|
||||
// Prefix is the prefix to be used for peer IDs.
|
||||
Prefix string `yaml:"prefix"`
|
||||
|
||||
// MinPort is the minimum port (inclusive) for the generated peer.
|
||||
MinPort int `yaml:"min_port"`
|
||||
|
||||
// MaxPort is the maximum port (exclusive) for the generated peer.
|
||||
MaxPort int `yaml:"max_port"`
|
||||
}
|
||||
|
||||
// newConfig parses the given MiddlewareConfig as a deniability.Config.
|
||||
func newConfig(mwcfg chihaya.MiddlewareConfig) (*Config, error) {
|
||||
bytes, err := yaml.Marshal(mwcfg.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
err = yaml.Unmarshal(bytes, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
63
middleware/deniability/config_test.go
Normal file
63
middleware/deniability/config_test.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package deniability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/chihaya/chihaya"
|
||||
)
|
||||
|
||||
type configTestData struct {
|
||||
modifyProbability string
|
||||
maxNewPeers string
|
||||
prefix string
|
||||
minPort string
|
||||
maxPort string
|
||||
err bool
|
||||
expected Config
|
||||
}
|
||||
|
||||
var (
|
||||
configTemplate = `
|
||||
name: foo
|
||||
config:
|
||||
modify_response_probability: %s
|
||||
max_random_peers: %s
|
||||
prefix: %s
|
||||
min_port: %s
|
||||
max_port: %s`
|
||||
|
||||
configData = []configTestData{
|
||||
{"1.0", "5", "abc", "2000", "3000", false, Config{1.0, 5, "abc", 2000, 3000}},
|
||||
{"a", "a", "12", "a", "a", true, Config{}},
|
||||
}
|
||||
)
|
||||
|
||||
func TestNewConfig(t *testing.T) {
|
||||
var mwconfig chihaya.MiddlewareConfig
|
||||
|
||||
cfg, err := newConfig(mwconfig)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, cfg)
|
||||
|
||||
for _, test := range configData {
|
||||
config := fmt.Sprintf(configTemplate, test.modifyProbability, test.maxNewPeers, test.prefix, test.minPort, test.maxPort)
|
||||
err = yaml.Unmarshal([]byte(config), &mwconfig)
|
||||
assert.Nil(t, err)
|
||||
|
||||
cfg, err = newConfig(mwconfig)
|
||||
if test.err {
|
||||
assert.NotNil(t, err)
|
||||
continue
|
||||
}
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, test.expected, *cfg)
|
||||
}
|
||||
}
|
121
middleware/deniability/deniability.go
Normal file
121
middleware/deniability/deniability.go
Normal file
|
@ -0,0 +1,121 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package deniability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/chihaya/chihaya"
|
||||
"github.com/chihaya/chihaya/pkg/random"
|
||||
"github.com/chihaya/chihaya/tracker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tracker.RegisterAnnounceMiddlewareConstructor("deniability", constructor)
|
||||
}
|
||||
|
||||
type deniabilityMiddleware struct {
|
||||
cfg *Config
|
||||
r *rand.Rand
|
||||
}
|
||||
|
||||
// constructor provides a middleware constructor that returns a middleware to
|
||||
// insert peers into the peer lists returned as a response to an announce.
|
||||
//
|
||||
// It returns an error if the config provided is either syntactically or
|
||||
// semantically incorrect.
|
||||
func constructor(c chihaya.MiddlewareConfig) (tracker.AnnounceMiddleware, error) {
|
||||
cfg, err := newConfig(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.ModifyResponseProbability <= 0 || cfg.ModifyResponseProbability > 1 {
|
||||
return nil, errors.New("modify_response_probability must be in [0,1)")
|
||||
}
|
||||
|
||||
if cfg.MaxRandomPeers <= 0 {
|
||||
return nil, errors.New("max_random_peers must be > 0")
|
||||
}
|
||||
|
||||
if cfg.MinPort <= 0 {
|
||||
return nil, errors.New("min_port must not be <= 0")
|
||||
}
|
||||
|
||||
if cfg.MaxPort > 65536 {
|
||||
return nil, errors.New("max_port must not be > 65536")
|
||||
}
|
||||
|
||||
if cfg.MinPort >= cfg.MaxPort {
|
||||
return nil, errors.New("max_port must not be <= min_port")
|
||||
}
|
||||
|
||||
if len(cfg.Prefix) > 20 {
|
||||
return nil, errors.New("prefix must not be longer than 20 bytes")
|
||||
}
|
||||
|
||||
mw := deniabilityMiddleware{
|
||||
cfg: cfg,
|
||||
r: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
|
||||
return mw.modifyResponse, nil
|
||||
}
|
||||
|
||||
func (mw *deniabilityMiddleware) modifyResponse(next tracker.AnnounceHandler) tracker.AnnounceHandler {
|
||||
return func(cfg *chihaya.TrackerConfig, req *chihaya.AnnounceRequest, resp *chihaya.AnnounceResponse) error {
|
||||
err := next(cfg, req, resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mw.cfg.ModifyResponseProbability == 1 || mw.r.Float32() < mw.cfg.ModifyResponseProbability {
|
||||
numNewPeers := mw.r.Intn(mw.cfg.MaxRandomPeers) + 1
|
||||
for i := 0; i < numNewPeers; i++ {
|
||||
if len(resp.IPv6Peers) > 0 {
|
||||
if len(resp.IPv6Peers) >= int(req.NumWant) {
|
||||
mw.replacePeer(resp.IPv6Peers, true)
|
||||
} else {
|
||||
resp.IPv6Peers = mw.insertPeer(resp.IPv6Peers, true)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.IPv4Peers) > 0 {
|
||||
if len(resp.IPv4Peers) >= int(req.NumWant) {
|
||||
mw.replacePeer(resp.IPv4Peers, false)
|
||||
} else {
|
||||
resp.IPv4Peers = mw.insertPeer(resp.IPv4Peers, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// replacePeer replaces a peer from a random position within the given slice
|
||||
// of peers with a randomly generated one.
|
||||
//
|
||||
// replacePeer panics if len(peers) == 0.
|
||||
func (mw *deniabilityMiddleware) replacePeer(peers []chihaya.Peer, v6 bool) {
|
||||
peers[mw.r.Intn(len(peers))] = random.Peer(mw.r, mw.cfg.Prefix, v6, mw.cfg.MinPort, mw.cfg.MaxPort)
|
||||
}
|
||||
|
||||
// insertPeer inserts a randomly generated peer at a random position into the
|
||||
// given slice and returns the new slice.
|
||||
func (mw *deniabilityMiddleware) insertPeer(peers []chihaya.Peer, v6 bool) []chihaya.Peer {
|
||||
pos := 0
|
||||
if len(peers) > 0 {
|
||||
pos = mw.r.Intn(len(peers))
|
||||
}
|
||||
peers = append(peers, chihaya.Peer{})
|
||||
copy(peers[pos+1:], peers[pos:])
|
||||
peers[pos] = random.Peer(mw.r, mw.cfg.Prefix, v6, mw.cfg.MinPort, mw.cfg.MaxPort)
|
||||
|
||||
return peers
|
||||
}
|
110
middleware/deniability/deniability_test.go
Normal file
110
middleware/deniability/deniability_test.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package deniability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/chihaya/chihaya"
|
||||
)
|
||||
|
||||
type constructorTestData struct {
|
||||
cfg Config
|
||||
error bool
|
||||
}
|
||||
|
||||
var constructorData = []constructorTestData{
|
||||
{Config{1.0, 10, "abc", 1024, 1025}, false},
|
||||
{Config{1.1, 10, "abc", 1024, 1025}, true},
|
||||
{Config{0, 10, "abc", 1024, 1025}, true},
|
||||
{Config{1.0, 0, "abc", 1024, 1025}, true},
|
||||
{Config{1.0, 10, "01234567890123456789_", 1024, 1025}, true},
|
||||
{Config{1.0, 10, "abc", 0, 1025}, true},
|
||||
{Config{1.0, 10, "abc", 1024, 0}, true},
|
||||
{Config{1.0, 10, "abc", 1024, 65537}, true},
|
||||
}
|
||||
|
||||
func TestReplacePeer(t *testing.T) {
|
||||
cfg := Config{
|
||||
Prefix: "abc",
|
||||
MinPort: 1024,
|
||||
MaxPort: 1025,
|
||||
}
|
||||
mw := deniabilityMiddleware{
|
||||
r: rand.New(rand.NewSource(0)),
|
||||
cfg: &cfg,
|
||||
}
|
||||
peer := chihaya.Peer{
|
||||
ID: chihaya.PeerID("abcdefghijklmnoprstu"),
|
||||
Port: 2000,
|
||||
IP: net.ParseIP("10.150.255.23"),
|
||||
}
|
||||
peers := []chihaya.Peer{peer}
|
||||
|
||||
mw.replacePeer(peers, false)
|
||||
assert.Equal(t, 1, len(peers))
|
||||
assert.Equal(t, "abc", string(peers[0].ID[:3]))
|
||||
assert.Equal(t, uint16(1024), peers[0].Port)
|
||||
assert.NotNil(t, peers[0].IP.To4())
|
||||
|
||||
mw.replacePeer(peers, true)
|
||||
assert.Equal(t, 1, len(peers))
|
||||
assert.Equal(t, "abc", string(peers[0].ID[:3]))
|
||||
assert.Equal(t, uint16(1024), peers[0].Port)
|
||||
assert.Nil(t, peers[0].IP.To4())
|
||||
|
||||
peers = []chihaya.Peer{peer, peer}
|
||||
|
||||
mw.replacePeer(peers, true)
|
||||
assert.True(t, (peers[0].Port == peer.Port) != (peers[1].Port == peer.Port), "not exactly one peer was replaced")
|
||||
}
|
||||
|
||||
func TestInsertPeer(t *testing.T) {
|
||||
cfg := Config{
|
||||
Prefix: "abc",
|
||||
MinPort: 1024,
|
||||
MaxPort: 1025,
|
||||
}
|
||||
mw := deniabilityMiddleware{
|
||||
r: rand.New(rand.NewSource(0)),
|
||||
cfg: &cfg,
|
||||
}
|
||||
peer := chihaya.Peer{
|
||||
ID: chihaya.PeerID("abcdefghijklmnoprstu"),
|
||||
Port: 2000,
|
||||
IP: net.ParseIP("10.150.255.23"),
|
||||
}
|
||||
var peers []chihaya.Peer
|
||||
|
||||
peers = mw.insertPeer(peers, false)
|
||||
assert.Equal(t, 1, len(peers))
|
||||
assert.Equal(t, uint16(1024), peers[0].Port)
|
||||
assert.Equal(t, "abc", string(peers[0].ID[:3]))
|
||||
assert.NotNil(t, peers[0].IP.To4())
|
||||
|
||||
peers = []chihaya.Peer{peer, peer}
|
||||
|
||||
peers = mw.insertPeer(peers, true)
|
||||
assert.Equal(t, 3, len(peers))
|
||||
}
|
||||
|
||||
func TestConstructor(t *testing.T) {
|
||||
for _, tt := range constructorData {
|
||||
_, err := constructor(chihaya.MiddlewareConfig{
|
||||
Config: tt.cfg,
|
||||
})
|
||||
|
||||
if tt.error {
|
||||
assert.NotNil(t, err, fmt.Sprintf("error expected for %+v", tt.cfg))
|
||||
} else {
|
||||
assert.Nil(t, err, fmt.Sprintf("no error expected for %+v", tt.cfg))
|
||||
}
|
||||
}
|
||||
}
|
74
pkg/random/peer.go
Normal file
74
pkg/random/peer.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package random
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net"
|
||||
|
||||
"github.com/chihaya/chihaya"
|
||||
)
|
||||
|
||||
// Peer generates a random chihaya.Peer.
|
||||
//
|
||||
// prefix is the prefix to use for the peer ID. If len(prefix) > 20, it will be
|
||||
// truncated to 20 characters. If len(prefix) < 20, it will be padded with an
|
||||
// alphanumeric random string to have 20 characters.
|
||||
//
|
||||
// v6 indicates whether an IPv6 address should be generated.
|
||||
// Regardless of the length of the generated IP address, its bytes will have
|
||||
// values in [1,254].
|
||||
//
|
||||
// minPort and maxPort describe the range for the randomly generated port, where
|
||||
// minPort <= port < maxPort.
|
||||
// minPort and maxPort will be checked and altered so that
|
||||
// 1 <= minPort <= maxPort <= 65536.
|
||||
// If minPort == maxPort, port will be set to minPort.
|
||||
func Peer(r *rand.Rand, prefix string, v6 bool, minPort, maxPort int) chihaya.Peer {
|
||||
var (
|
||||
port uint16
|
||||
ip net.IP
|
||||
)
|
||||
|
||||
if minPort <= 0 {
|
||||
minPort = 1
|
||||
}
|
||||
if maxPort > 65536 {
|
||||
maxPort = 65536
|
||||
}
|
||||
if maxPort < minPort {
|
||||
maxPort = minPort
|
||||
}
|
||||
if len(prefix) > 20 {
|
||||
prefix = prefix[:20]
|
||||
}
|
||||
|
||||
if minPort == maxPort {
|
||||
port = uint16(minPort)
|
||||
} else {
|
||||
port = uint16(r.Int63()%int64(maxPort-minPort)) + uint16(minPort)
|
||||
}
|
||||
|
||||
if v6 {
|
||||
b := make([]byte, 16)
|
||||
ip = net.IP(b)
|
||||
} else {
|
||||
b := make([]byte, 4)
|
||||
ip = net.IP(b)
|
||||
}
|
||||
|
||||
for i := range ip {
|
||||
b := r.Intn(254) + 1
|
||||
ip[i] = byte(b)
|
||||
}
|
||||
|
||||
prefix = prefix + AlphaNumericString(r, 20-len(prefix))
|
||||
|
||||
return chihaya.Peer{
|
||||
ID: chihaya.PeerID(prefix),
|
||||
Port: port,
|
||||
IP: ip,
|
||||
}
|
||||
}
|
43
pkg/random/peer_test.go
Normal file
43
pkg/random/peer_test.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package random
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPeer(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(0))
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
minPort := 2000
|
||||
maxPort := 2010
|
||||
p := Peer(r, "", false, minPort, maxPort)
|
||||
assert.Equal(t, 20, len(p.ID))
|
||||
assert.True(t, p.Port >= uint16(minPort) && p.Port < uint16(maxPort))
|
||||
assert.NotNil(t, p.IP.To4())
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
minPort := 2000
|
||||
maxPort := 2010
|
||||
p := Peer(r, "", true, minPort, maxPort)
|
||||
assert.Equal(t, 20, len(p.ID))
|
||||
assert.True(t, p.Port >= uint16(minPort) && p.Port < uint16(maxPort))
|
||||
assert.True(t, len(p.IP) == net.IPv6len)
|
||||
}
|
||||
|
||||
p := Peer(r, "abcdefghijklmnopqrst", false, 2000, 2000)
|
||||
assert.Equal(t, "abcdefghijklmnopqrst", string(p.ID))
|
||||
assert.Equal(t, uint16(2000), p.Port)
|
||||
|
||||
p = Peer(r, "abcdefghijklmnopqrstUVWXYZ", true, -10, -5)
|
||||
assert.Equal(t, "abcdefghijklmnopqrst", string(p.ID))
|
||||
assert.True(t, p.Port >= uint16(1) && p.Port <= uint16(65535))
|
||||
}
|
26
pkg/random/string.go
Normal file
26
pkg/random/string.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package random
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// AlphaNumeric is an alphabet with all lower- and uppercase letters and
|
||||
// numbers.
|
||||
const AlphaNumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// AlphaNumericString is a shorthand for String(r, l, AlphaNumeric).
|
||||
func AlphaNumericString(r rand.Source, l int) string {
|
||||
return String(r, l, AlphaNumeric)
|
||||
}
|
||||
|
||||
// String generates a random string of length l, containing only runes from
|
||||
// the alphabet using the random source r.
|
||||
func String(r rand.Source, l int, alphabet string) string {
|
||||
b := make([]byte, l)
|
||||
for i := range b {
|
||||
b[i] = alphabet[r.Int63()%int64(len(alphabet))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
30
pkg/random/string_test.go
Normal file
30
pkg/random/string_test.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
// Copyright 2016 The Chihaya Authors. All rights reserved.
|
||||
// Use of this source code is governed by the BSD 2-Clause license,
|
||||
// which can be found in the LICENSE file.
|
||||
|
||||
package random
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAlphaNumericString(t *testing.T) {
|
||||
r := rand.NewSource(0)
|
||||
|
||||
s := AlphaNumericString(r, 0)
|
||||
assert.Equal(t, 0, len(s))
|
||||
|
||||
s = AlphaNumericString(r, 10)
|
||||
assert.Equal(t, 10, len(s))
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
s := AlphaNumericString(r, 10)
|
||||
for _, c := range s {
|
||||
assert.True(t, strings.Contains(AlphaNumeric, string(c)))
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue