feat: 统一服务缓存 + 指纹驱动插件匹配
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled

将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果
统一缓存,插件匹配时端口不命中则回退到服务名称匹配。

删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。
补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
This commit is contained in:
ZacharyZcR
2026-06-12 19:49:07 +08:00
parent 517133f72f
commit 1595c92aed
44 changed files with 1533 additions and 142 deletions
+28 -4
View File
@@ -192,9 +192,9 @@ func TestGenerateCredentials_PlaceholderReplacement(t *testing.T) {
// 验证:{user} 被正确替换
expectedCombos := map[string]string{
"root:root": "root", // {user} → root
"root:root123": "root", // {user}123 → root123
"mysql:mysql": "mysql", // {user} → mysql
"root:root": "root", // {user} → root
"root:root123": "root", // {user}123 → root123
"mysql:mysql": "mysql", // {user} → mysql
"mysql:mysql123": "mysql", // {user}123 → mysql123
}
@@ -244,7 +244,7 @@ func TestGenerateCredentials_DefaultValues(t *testing.T) {
cfg.Credentials.UserPassPairs = []config.CredentialPair{}
cfg.Credentials.Userdict = map[string][]string{} // 空字典
cfg.Credentials.Passwords = []string{} // 空密码列表
cfg.Credentials.Passwords = []string{} // 空密码列表
result := GenerateCredentials("unknown_service", cfg)
@@ -327,3 +327,27 @@ func TestGenerateCredentials_EmptyUserPassPairs(t *testing.T) {
t.Logf("✓ 空 UserPassPairs 正确回退到笛卡尔积")
}
func TestBuildConfigAdditionalPasswordsAreNotShadowedByExactPair(t *testing.T) {
cfg, _, err := common.BuildConfig(&common.FlagVars{
Username: "root",
Password: "primary",
AddPasswords: "extra",
}, &common.HostInfo{})
if err != nil {
t.Fatalf("BuildConfig error = %v", err)
}
result := GenerateCredentials("ssh", cfg)
found := map[string]bool{}
for _, cred := range result {
found[cred.Username+":"+cred.Password] = true
}
if !found["root:primary"] {
t.Fatal("missing primary password credential")
}
if !found["root:extra"] {
t.Fatal("additional password was shadowed by exact user/password pair")
}
}
+11
View File
@@ -0,0 +1,11 @@
package local
import (
"fmt"
"net"
"strconv"
)
func ldapURL(host string, port int) string {
return fmt.Sprintf("ldap://%s", net.JoinHostPort(host, strconv.Itoa(port)))
}
+24
View File
@@ -0,0 +1,24 @@
package local
import "testing"
func TestLDAPURLUsesJoinHostPort(t *testing.T) {
tests := []struct {
name string
host string
port int
want string
}{
{name: "hostname", host: "dc.example.local", port: 389, want: "ldap://dc.example.local:389"},
{name: "ipv4", host: "192.168.1.10", port: 389, want: "ldap://192.168.1.10:389"},
{name: "ipv6", host: "2001:db8::10", port: 389, want: "ldap://[2001:db8::10]:389"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ldapURL(tt.host, tt.port); got != tt.want {
t.Fatalf("ldapURL(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
}
})
}
}
+2 -2
View File
@@ -82,10 +82,10 @@ func (p *SystemInfoPlugin) connectToDomain(domain string) (*domainInfo, error) {
}
defer func() { _ = client.Close() }()
conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:389", dcHost))
conn, err := ldap.DialURL(ldapURL(dcHost, 389))
if err != nil {
if ipv4, resolveErr := resolveIPv4(dcHost); resolveErr == nil {
conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", ipv4))
conn, err = ldap.DialURL(ldapURL(ipv4, 389))
}
if err != nil {
return nil, fmt.Errorf("LDAP dial: %w", err)
+8 -8
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -165,20 +166,19 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host
// ── CQL wire protocol 工具 ──────────────────────────────────────
var cqlStreamID int16
var cqlStreamID uint32
func nextCQLStreamID() uint16 {
return uint16((atomic.AddUint32(&cqlStreamID, 1) - 1) & 0x7fff)
}
func cqlSend(conn net.Conn, opcode byte, body []byte) error {
id := cqlStreamID
if cqlStreamID == 32767 {
cqlStreamID = 0
} else {
cqlStreamID++
}
id := nextCQLStreamID()
// frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body]
header := make([]byte, 8)
header[0] = cqlVersion
binary.BigEndian.PutUint16(header[1:3], uint16(id))
binary.BigEndian.PutUint16(header[1:3], id)
header[3] = opcode
binary.BigEndian.PutUint32(header[4:8], uint32(len(body)))
+76 -12
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"sync"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -61,7 +62,11 @@ type AuthFunc func(ctx context.Context, cred Credential) *AuthResult
// ErrorClassifier 错误分类函数
type ErrorClassifier func(err error) ErrorType
var authCleanupWait = 2 * time.Second
var authCleanupWaitNanos int64 = int64(2 * time.Second)
func authCleanupWait() time.Duration {
return time.Duration(atomic.LoadInt64(&authCleanupWaitNanos))
}
// =============================================================================
// 单凭据测试(解决 goroutine 泄漏)
@@ -70,9 +75,36 @@ var authCleanupWait = 2 * time.Second
// TestSingleCredential 安全地测试单个凭据
// 正确处理 context 取消时的资源清理
func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc) *AuthResult {
if ctx == nil {
ctx = context.Background()
}
if authFn == nil {
return &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function is nil"),
}
}
if err := ctx.Err(); err != nil {
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: err,
}
}
resultChan := make(chan *AuthResult, 1)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function panic: %v", r),
}
}
}()
result := authFn(ctx, cred)
resultChan <- result
}()
@@ -83,7 +115,7 @@ func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc)
case <-ctx.Done():
// context 被取消后只做有界等待,避免 authFn 卡死时清理 goroutine 也永久泄漏。
go func() {
timer := time.NewTimer(authCleanupWait)
timer := time.NewTimer(authCleanupWait())
defer timer.Stop()
select {
@@ -116,15 +148,35 @@ type ConcurrentTestConfig struct {
UseProxy bool // 代理模式下跳过直连 TCP 预检
}
func normalizeConcurrentTestConfig(testConfig ConcurrentTestConfig) ConcurrentTestConfig {
if testConfig.Concurrency <= 0 {
testConfig.Concurrency = 10
}
if testConfig.MaxRetries <= 0 {
testConfig.MaxRetries = 3
}
if testConfig.RetryDelay <= 0 {
testConfig.RetryDelay = time.Second
}
if testConfig.MaxConsecutiveNetErrors <= 0 {
testConfig.MaxConsecutiveNetErrors = 5
}
return testConfig
}
// DefaultConcurrentTestConfig 默认配置
func DefaultConcurrentTestConfig(config *common.Config) ConcurrentTestConfig {
concurrency := config.ModuleThreadNum
if concurrency <= 0 {
concurrency = 10
}
maxRetries := config.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
return ConcurrentTestConfig{
Concurrency: concurrency,
MaxRetries: 3,
MaxRetries: maxRetries,
RetryDelay: time.Second,
MaxConsecutiveNetErrors: 5,
UseProxy: config.Network.Socks5Proxy != "" || config.Network.HTTPProxy != "",
@@ -147,6 +199,9 @@ func TestCredentialsConcurrently(
serviceName string,
testConfig ConcurrentTestConfig,
) *ScanResult {
if ctx == nil {
ctx = context.Background()
}
if len(credentials) == 0 {
return &ScanResult{
Success: false,
@@ -154,11 +209,16 @@ func TestCredentialsConcurrently(
Error: fmt.Errorf("%s", i18n.GetText("service_no_test_creds")),
}
}
testConfig = normalizeConcurrentTestConfig(testConfig)
// TCP 预检:快速验证目标可达,避免对不可达目标浪费全部凭据尝试
// 代理模式下跳过:net.DialTimeout 直连无法到达代理后的内网目标
if testConfig.TargetAddr != "" && !testConfig.UseProxy {
preConn, err := net.DialTimeout("tcp", testConfig.TargetAddr, 3*time.Second)
dialCtx, dialCancel := context.WithTimeout(ctx, 3*time.Second)
defer dialCancel()
var dialer net.Dialer
preConn, err := dialer.DialContext(dialCtx, "tcp", testConfig.TargetAddr)
if err != nil {
return &ScanResult{
Success: false,
@@ -240,10 +300,6 @@ func workerTestCredentials(
testConfig ConcurrentTestConfig,
) {
consecutiveNetErrors := 0
maxNetErrors := testConfig.MaxConsecutiveNetErrors
if maxNetErrors <= 0 {
maxNetErrors = 5
}
for cred := range credChan {
// 检查是否应该停止
@@ -254,7 +310,7 @@ func workerTestCredentials(
}
// 连续网络错误达到阈值,目标可能不可达,提前退出
if consecutiveNetErrors >= maxNetErrors {
if consecutiveNetErrors >= testConfig.MaxConsecutiveNetErrors {
return
}
@@ -292,10 +348,18 @@ func testCredentialWithRetry(
// 测试凭据
result := TestSingleCredential(ctx, cred, authFn)
if result == nil {
result = &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function returned nil result"),
}
}
if result.Success && result.Conn != nil {
// 成功,关闭连接并返回
_ = result.Conn.Close()
if result.Success {
if result.Conn != nil {
_ = result.Conn.Close()
}
return &ScanResult{
Type: plugins.ResultTypeCredential,
Success: true,
+217 -3
View File
@@ -8,6 +8,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
/*
@@ -187,6 +189,84 @@ func TestMatchIgnoreCase(t *testing.T) {
// 并发测试
// =============================================================================
func setAuthCleanupWaitForTest(wait time.Duration) func() {
oldWait := atomic.LoadInt64(&authCleanupWaitNanos)
atomic.StoreInt64(&authCleanupWaitNanos, int64(wait))
return func() { atomic.StoreInt64(&authCleanupWaitNanos, oldWait) }
}
func TestDefaultConcurrentTestConfigUsesConfigRetries(t *testing.T) {
cfg := DefaultConcurrentTestConfig(&common.Config{
ModuleThreadNum: 7,
MaxRetries: 5,
})
if cfg.Concurrency != 7 {
t.Fatalf("Concurrency = %d, want 7", cfg.Concurrency)
}
if cfg.MaxRetries != 5 {
t.Fatalf("MaxRetries = %d, want config MaxRetries 5", cfg.MaxRetries)
}
}
func TestDefaultConcurrentTestConfigRetriesFallback(t *testing.T) {
cfg := DefaultConcurrentTestConfig(&common.Config{
ModuleThreadNum: 0,
MaxRetries: 0,
})
if cfg.Concurrency != 10 {
t.Fatalf("Concurrency = %d, want fallback 10", cfg.Concurrency)
}
if cfg.MaxRetries != 3 {
t.Fatalf("MaxRetries = %d, want fallback 3", cfg.MaxRetries)
}
}
func TestTestCredentialsConcurrently_ZeroValueConfigStillRuns(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: true}
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "u", Password: "p"}}, authFn, "test", ConcurrentTestConfig{})
if !result.Success {
t.Fatalf("zero-value config should still test credentials: %v", result.Error)
}
if calls.Load() != 1 {
t.Fatalf("authFn calls = %d, want 1", calls.Load())
}
}
func TestTestCredentialsConcurrently_PrecheckHonorsCanceledContext(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: false}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
result := TestCredentialsConcurrently(ctx, []Credential{{Username: "u", Password: "p"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 1,
TargetAddr: "203.0.113.1:65000",
})
if result.Success {
t.Fatal("canceled context should not return success")
}
if calls.Load() != 0 {
t.Fatalf("authFn calls = %d, want 0 when precheck context is canceled", calls.Load())
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("precheck ignored canceled context, elapsed=%v", elapsed)
}
}
// mockConn 模拟连接
type mockConn struct {
closed atomic.Bool
@@ -336,6 +416,60 @@ func TestTestCredentialsConcurrently_ContextCancel(t *testing.T) {
}
}
func TestTestCredentialsConcurrently_CancelWithStuckAuthReturnsPromptly(t *testing.T) {
defer setAuthCleanupWaitForTest(20 * time.Millisecond)()
credentials := make([]Credential, 10)
for i := range credentials {
credentials[i] = Credential{Username: "user", Password: "pass"}
}
authStarted := make(chan struct{}, len(credentials))
releaseAuth := make(chan struct{})
authFn := func(ctx context.Context, cred Credential) *AuthResult {
authStarted <- struct{}{}
<-releaseAuth
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork}
}
config := ConcurrentTestConfig{
Concurrency: 3,
MaxRetries: 1,
RetryDelay: time.Millisecond,
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan *ScanResult, 1)
go func() {
done <- TestCredentialsConcurrently(ctx, credentials, authFn, "test", config)
}()
for i := 0; i < config.Concurrency; i++ {
select {
case <-authStarted:
case <-time.After(time.Second):
close(releaseAuth)
t.Fatalf("authFn started %d workers, want %d", i, config.Concurrency)
}
}
start := time.Now()
cancel()
select {
case result := <-done:
close(releaseAuth)
if result.Success {
t.Fatal("context取消后不应该返回成功")
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("取消后返回过慢: %v", elapsed)
}
case <-time.After(time.Second):
close(releaseAuth)
t.Fatal("authFn 卡住时并发测试没有及时返回")
}
}
// =============================================================================
// 单凭据测试
// =============================================================================
@@ -361,6 +495,49 @@ func TestTestSingleCredential_Success(t *testing.T) {
}
}
func TestTestSingleCredential_CanceledContextSkipsAuth(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: true}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := TestSingleCredential(ctx, Credential{Username: "admin", Password: "admin"}, authFn)
if result.Success {
t.Fatal("canceled context should not return success")
}
if calls.Load() != 0 {
t.Fatalf("authFn calls = %d, want 0", calls.Load())
}
}
func TestTestSingleCredential_NilAuthFunc(t *testing.T) {
result := TestSingleCredential(context.Background(), Credential{Username: "admin", Password: "admin"}, nil)
if result.Success {
t.Fatal("nil authFn should not return success")
}
if result.Error == nil {
t.Fatal("nil authFn should return an error")
}
}
func TestTestSingleCredential_RecoverAuthPanic(t *testing.T) {
authFn := func(ctx context.Context, cred Credential) *AuthResult {
panic("boom")
}
result := TestSingleCredential(context.Background(), Credential{Username: "admin", Password: "admin"}, authFn)
if result.Success {
t.Fatal("panic authFn should not return success")
}
if result.Error == nil {
t.Fatal("panic authFn should return an error")
}
}
// TestTestSingleCredential_ContextCancel 测试context取消时的资源清理
func TestTestSingleCredential_ContextCancel(t *testing.T) {
conn := &mockConn{}
@@ -403,9 +580,7 @@ func TestTestSingleCredential_ContextCancel(t *testing.T) {
}
func TestTestSingleCredential_ContextCancelCleanupIsBounded(t *testing.T) {
oldWait := authCleanupWait
authCleanupWait = 20 * time.Millisecond
defer func() { authCleanupWait = oldWait }()
defer setAuthCleanupWaitForTest(20 * time.Millisecond)()
authStarted := make(chan struct{})
releaseAuth := make(chan struct{})
@@ -480,6 +655,45 @@ func TestRetryLogic_NetworkErrorRetries(t *testing.T) {
}
}
func TestRetryLogic_SuccessWithoutConn(t *testing.T) {
var attempts atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
attempts.Add(1)
return &AuthResult{Success: true}
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "admin", Password: "admin"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 3,
})
if !result.Success {
t.Fatalf("success result without Conn should be accepted: %v", result.Error)
}
if attempts.Load() != 1 {
t.Fatalf("attempts = %d, want 1", attempts.Load())
}
}
func TestRetryLogic_NilAuthResultDoesNotPanic(t *testing.T) {
var attempts atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
attempts.Add(1)
return nil
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "admin", Password: "admin"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 2,
RetryDelay: time.Millisecond,
})
if result.Success {
t.Fatal("nil auth result should not return success")
}
if attempts.Load() != 2 {
t.Fatalf("attempts = %d, want 2", attempts.Load())
}
}
// TestRetryLogic_AuthErrorNoRetry 认证错误不应该重试
func TestRetryLogic_AuthErrorNoRetry(t *testing.T) {
var attempts atomic.Int32
+6 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -154,9 +155,12 @@ func (p *KafkaPlugin) doKafkaAuth(ctx context.Context, info *common.HostInfo, cr
var kafkaCorrelationID int32
func nextKafkaCorrelationID() int32 {
return atomic.AddInt32(&kafkaCorrelationID, 1) - 1
}
func kafkaSend(conn net.Conn, apiKey, apiVersion int16, body []byte) error {
corrID := kafkaCorrelationID
kafkaCorrelationID++
corrID := nextKafkaCorrelationID()
// 请求格式: [4B len] [2B api_key] [2B api_version] [4B corr_id] [2B client_id_len] [client_id] [body]
clientID := "fscan"
+2 -2
View File
@@ -11,6 +11,7 @@ import (
"io"
"net"
"strings"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -156,8 +157,7 @@ const (
var mongoRequestID uint32
func nextRequestID() uint32 {
mongoRequestID++
return mongoRequestID
return atomic.AddUint32(&mongoRequestID, 1)
}
// buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现)
+46
View File
@@ -0,0 +1,46 @@
package services
import (
"sync"
"testing"
)
func TestProtocolIDsAreConcurrentSafe(t *testing.T) {
const workers = 64
const perWorker = 64
tests := []struct {
name string
next func() uint32
}{
{"mongodb", nextRequestID},
{"kafka", func() uint32 { return uint32(nextKafkaCorrelationID()) }},
{"cassandra", func() uint32 { return uint32(nextCQLStreamID()) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var wg sync.WaitGroup
values := make(chan uint32, workers*perWorker)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perWorker; j++ {
values <- tt.next()
}
}()
}
wg.Wait()
close(values)
seen := make(map[uint32]struct{}, workers*perWorker)
for value := range values {
if _, ok := seen[value]; ok {
t.Fatalf("duplicate protocol id %d", value)
}
seen[value] = struct{}{}
}
})
}
}
+24 -4
View File
@@ -6,9 +6,11 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -131,7 +133,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
isGM = true
urlScheme = "https" // 国密连接仍使用 https URL 格式
}
baseURL := fmt.Sprintf("%s://%s:%d", urlScheme, info.Host, info.Port)
baseURL := webTitleURL(urlScheme, info.Host, info.Port)
// 选择对应的 HTTP 客户端
clientNR, clientR := lib.ClientNoRedirect, lib.Client
@@ -142,11 +144,11 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
// 构建显示用URL(隐藏标准端口)
var displayURL string
if isGM && info.Port == 443 {
displayURL = fmt.Sprintf("%s://%s", protocol, info.Host)
displayURL = webTitleDisplayURL(protocol, info.Host, info.Port, true)
} else if (protocol == "https" && info.Port == 443) || (protocol == "http" && info.Port == 80) {
displayURL = fmt.Sprintf("%s://%s", protocol, info.Host)
displayURL = webTitleDisplayURL(protocol, info.Host, info.Port, true)
} else {
displayURL = fmt.Sprintf("%s://%s:%d", protocol, info.Host, info.Port)
displayURL = webTitleDisplayURL(protocol, info.Host, info.Port, false)
}
req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
@@ -221,6 +223,24 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
return title, statusCode, contentLen, server, fingerprints, displayURL, nil
}
func webTitleURL(scheme, host string, port int) string {
return (&url.URL{Scheme: scheme, Host: net.JoinHostPort(host, strconv.Itoa(port))}).String()
}
func webTitleDisplayURL(scheme, host string, port int, omitPort bool) string {
if omitPort {
return (&url.URL{Scheme: scheme, Host: urlHost(host)}).String()
}
return webTitleURL(scheme, host, port)
}
func urlHost(host string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]"
}
return host
}
// resolveRedirectURL 解析重定向URL,处理相对路径
func (p *WebTitlePlugin) resolveRedirectURL(baseURL, location string) string {
// 如果是绝对URL,直接返回
+21
View File
@@ -35,3 +35,24 @@ func TestFetchFaviconHashHonorsContext(t *testing.T) {
t.Fatalf("fetchFaviconHash returned hashes for canceled context: %#v", hashes)
}
}
func TestWebTitleURLUsesJoinHostPort(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{"ipv4", webTitleURL("http", "127.0.0.1", 8080), "http://127.0.0.1:8080"},
{"ipv6", webTitleURL("http", "::1", 8080), "http://[::1]:8080"},
{"ipv6 display with port", webTitleDisplayURL("https", "2001:db8::1", 8443, false), "https://[2001:db8::1]:8443"},
{"ipv6 display omit port", webTitleDisplayURL("https", "2001:db8::1", 443, true), "https://[2001:db8::1]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Fatalf("got %q, want %q", tt.got, tt.want)
}
})
}
}