From c9558cf36da0cfecc4de1ac789bbb028d2bb7b69 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Fri, 5 Sep 2025 14:34:39 +0300 Subject: [PATCH 1/5] Support SSH URLs with port numbers and improve validation The main changes are: - Update SSH URL regex to handle URLs with port numbers (e.g. ssh://user@host:port/path) - Add support for ~ character in valid repo path pattern - Improve URL format detection efficiency by using string prefix checks - Fix regex group handling for SSH URLs with optional port number - Add test cases for alternative SSH URL formats The commit focuses on improving SSH URL handling while maintaining security validation. --- main.go | 184 +++++++++++++++++++++++++++------------------------ main_test.go | 92 ++++++++++++++++++++++---- 2 files changed, 176 insertions(+), 100 deletions(-) diff --git a/main.go b/main.go index 3e671fe..2542ab5 100644 --- a/main.go +++ b/main.go @@ -58,7 +58,7 @@ type Environment interface { // Dependencies holds all external dependencies for the application type Dependencies struct { FS FileSystem - CmdRun CommandRunner + CmdRun CommandRunner GitClone GitCloner DirCheck DirectoryChecker Env Environment @@ -177,14 +177,14 @@ var ( // Config holds the configuration for the application type Config struct { - ShowCommandHelp bool - ShowVersionInfo bool - Quiet bool - ShallowClone bool - Workers int - RepositoryArgs []string - Dependencies *Dependencies - CacheConfig *CacheConfig + ShowCommandHelp bool + ShowVersionInfo bool + Quiet bool + ShallowClone bool + Workers int + RepositoryArgs []string + Dependencies *Dependencies + CacheConfig *CacheConfig } // ProcessingResult holds the result of repository processing @@ -292,7 +292,7 @@ func (wp *WorkerPool) processJob(job RepositoryJob, _ int) { // Security: Use secure git clone with validated arguments ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() - + if err := wp.config.Dependencies.GitClone.Clone(ctx, job.Repository, filepath.Dir(projectDir), wp.config.Quiet, wp.config.ShallowClone); err != nil { result.Error = fmt.Errorf("failed clone repository '%s': %w", job.Repository, err) wp.results <- result @@ -340,7 +340,7 @@ func (wp *WorkerPool) Start() *ProcessingResult { case workerResult := <-wp.results: result.ProcessedCount++ processedCount++ - + if workerResult.Success { result.LastSuccessfulProjectDir = workerResult.ProjectDir } else { @@ -420,7 +420,7 @@ func (wp *WorkerPool) StartWithSignalHandling() *ProcessingResult { fmt.Fprintf(os.Stderr, "\nReceived signal %v, initiating graceful shutdown...\n", sig) } wp.gracefulShutdown() - + // Wait for result with timeout select { case result := <-resultChan: @@ -466,21 +466,21 @@ var ( return regexp.MustCompile(`^https?://([^/]+)/(.+?)(?:\.git)?/?$`) }, } - - // SSH URLs (git@github.com:user/repo.git) + + // SSH URLs (git@github.com:user/repo.git or ssh://user@host:port/path) sshRegexPool = sync.Pool{ New: func() any { - return regexp.MustCompile(`^(?:ssh://)?([^@]+)@([^:]+):(.+?)(?:\.git)?/?$`) + return regexp.MustCompile(`^(?:ssh://)?([^@]+)@([^/:]+)(?::(\d+))?[:/](.+?)(?:\.git)?/?$`) }, } - + // Git protocol URLs (git://github.com/user/repo.git) gitRegexPool = sync.Pool{ New: func() any { return regexp.MustCompile(`^git://([^/]+)/(.+?)(?:\.git)?/?$`) }, } - + // Generic fallback regex pool (original pattern) genericRegexPool = sync.Pool{ New: func() any { @@ -522,7 +522,7 @@ func GetRegexStats() RegexPoolStats { func (stats *RegexPoolStats) incrementUsage(regexType RegexType) { stats.mutex.Lock() defer stats.mutex.Unlock() - + switch regexType { case RegexHTTPS: stats.HTTPSUsage++ @@ -551,34 +551,34 @@ func (stats *RegexPoolStats) incrementCacheMiss() { // CacheConfig holds configuration parameters for directory cache type CacheConfig struct { - TTL time.Duration - CleanupInterval time.Duration - MaxEntries int + TTL time.Duration + CleanupInterval time.Duration + MaxEntries int EnablePeriodicCleanup bool } // DefaultCacheConfig returns default cache configuration func DefaultCacheConfig() *CacheConfig { return &CacheConfig{ - TTL: 60 * time.Second, // Increased from 30s to 1 minute - CleanupInterval: 5 * time.Minute, - MaxEntries: 1000, + TTL: 60 * time.Second, // Increased from 30s to 1 minute + CleanupInterval: 5 * time.Minute, + MaxEntries: 1000, EnablePeriodicCleanup: true, } } type cacheEntry struct { - exists bool - timestamp time.Time + exists bool + timestamp time.Time lastAccess time.Time } // CacheStats holds statistics about cache performance type CacheStats struct { - Hits int64 - Misses int64 - Evictions int64 - TotalSize int64 + Hits int64 + Misses int64 + Evictions int64 + TotalSize int64 } // CachePerformance provides cache performance metrics @@ -612,15 +612,15 @@ var ( "ssh": true, "git": true, } - + // Dangerous characters that could be used for command injection dangerousChars = regexp.MustCompile(`[;&|$\x60<>(){}[\]!*?]`) - + // Valid hostname pattern - more restrictive than RFC but safer validHostname = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`) - + // Valid path characters for Git repositories - validRepoPath = regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) + validRepoPath = regexp.MustCompile(`^[a-zA-Z0-9._/~-]+$`) ) // validateRepositoryURL performs comprehensive security validation of repository URLs @@ -749,10 +749,10 @@ func secureGitClone(repository, targetDir string, quiet, shallow bool) error { } args = append(args, "--", repository) cmd := exec.CommandContext(ctx, "git", args...) - + // Set working directory cmd.Dir = cleanTargetDir - + // Configure output if !quiet { cmd.Stdout = os.Stderr @@ -766,7 +766,7 @@ func secureGitClone(repository, targetDir string, quiet, shallow bool) error { } return err } - + return nil } @@ -778,7 +778,7 @@ func parseArgs() (*Config, error) { CacheConfig: cacheConfig, Workers: getDefaultWorkers(), } - + pflag.BoolVarP(&config.ShowCommandHelp, "help", "h", false, "Show this help message and exit") pflag.BoolVarP(&config.ShowVersionInfo, "version", "v", false, "Show the version number and exit") pflag.BoolVarP(&config.Quiet, "quiet", "q", false, "Suppress output") @@ -787,7 +787,7 @@ func parseArgs() (*Config, error) { pflag.Parse() config.RepositoryArgs = pflag.Args() - + // Validate workers count if config.Workers < 1 { return nil, errors.New("workers count must be at least 1") @@ -795,7 +795,7 @@ func parseArgs() (*Config, error) { if config.Workers > 32 { return nil, errors.New("workers count cannot exceed 32") } - + // Validate that we have arguments (unless help or version is requested) if !config.ShowCommandHelp && !config.ShowVersionInfo && len(config.RepositoryArgs) == 0 { return nil, errors.New("no repository URLs provided") @@ -826,7 +826,7 @@ func processRepositories(config *Config) *ProcessingResult { if len(config.RepositoryArgs) == 1 || config.Workers == 1 { return processRepositoriesSequential(config) } - + // Use worker pool for multiple repositories with multiple workers wp := NewWorkerPool(config) return wp.StartWithSignalHandling() @@ -839,7 +839,7 @@ func processRepositoriesSequential(config *Config) *ProcessingResult { for _, arg := range config.RepositoryArgs { repository := strings.TrimSpace(arg) result.ProcessedCount++ - + // Security: Validate repository URL before processing if err := validateRepositoryURL(repository); err != nil { prnt("invalid repository URL '%s': %s", repository, err) @@ -895,7 +895,7 @@ func printSummary(config *Config, result *ProcessingResult) { // Print summary if multiple repositories were processed if result.ProcessedCount > 1 && !config.Quiet { successCount := result.ProcessedCount - result.FailedCount - prnt("processed %d repositories: %d successful, %d failed", + prnt("processed %d repositories: %d successful, %d failed", result.ProcessedCount, successCount, result.FailedCount) } @@ -957,13 +957,13 @@ func main() { // URLCache provides caching for normalized URLs to avoid repeated parsing type URLCache struct { - cache map[string]string - mutex sync.RWMutex + cache map[string]string + mutex sync.RWMutex maxEntries int } var urlCache = &URLCache{ - cache: make(map[string]string), + cache: make(map[string]string), maxEntries: 1000, } @@ -979,7 +979,7 @@ func (uc *URLCache) Get(key string) (string, bool) { func (uc *URLCache) Set(key, value string) { uc.mutex.Lock() defer uc.mutex.Unlock() - + // Simple eviction strategy: clear cache when full if len(uc.cache) >= uc.maxEntries { uc.cache = make(map[string]string) @@ -996,14 +996,20 @@ func (uc *URLCache) Clear() { // detectRegexType determines the best regex pattern for the given URL func detectRegexType(repo string) RegexType { - if len(repo) > 8 && (repo[:7] == "https://" || repo[:7] == "http://") { + // Check for HTTPS/HTTP URLs first + if strings.HasPrefix(repo, "https://") || strings.HasPrefix(repo, "http://") { return RegexHTTPS } - if strings.Contains(repo, "@") && strings.Contains(repo, ":") && !strings.Contains(repo, "://") { + // Check for Git protocol URLs + if strings.HasPrefix(repo, "git://") { + return RegexGit + } + // Check for SSH URLs (ssh:// prefix or git@host:path format) + if strings.HasPrefix(repo, "ssh://") { return RegexSSH } - if len(repo) > 6 && repo[:6] == "git://" { - return RegexGit + if strings.Contains(repo, "@") && strings.Contains(repo, ":") && !strings.Contains(repo, "://") { + return RegexSSH } return RegexGeneric } @@ -1050,7 +1056,7 @@ func normalize(repo string) (string, error) { defer putBack(r) var host, path string - + // Handle different URL patterns switch regexType { case RegexHTTPS, RegexGit: @@ -1059,14 +1065,16 @@ func normalize(repo string) (string, error) { return "", errors.New("failed to parse HTTPS/Git repository URL format") } host, path = match[1], match[2] - + case RegexSSH: match := r.FindStringSubmatch(repo) - if len(match) != 4 { + if len(match) < 4 { return "", errors.New("failed to parse SSH repository URL format") } - host, path = match[2], match[3] - + // match[1] = user, match[2] = host, match[3] = port (optional), match[4] = path + // For hostname validation, we only use the host part (without port) + host, path = match[2], match[4] + default: // RegexGeneric match := r.FindStringSubmatch(repo) if len(match) != 3 { @@ -1092,10 +1100,10 @@ func normalize(repo string) (string, error) { } result := filepath.Join(host, sanitizedPath) - + // Cache the result urlCache.Set(repo, result) - + return result, nil } @@ -1112,25 +1120,25 @@ func sanitizePathWithError(path string) (string, error) { // Use string slicing to avoid multiple allocations for { originalLen := len(path) - + // Remove leading slashes and tildes if len(path) > 0 && (path[0] == '/' || path[0] == '~') { path = path[1:] continue } - + // Remove trailing slashes if len(path) > 0 && path[len(path)-1] == '/' { path = path[:len(path)-1] continue } - + // Remove .git suffix if len(path) >= 4 && path[len(path)-4:] == ".git" { path = path[:len(path)-4] continue } - + // If no changes were made, break if len(path) == originalLen { break @@ -1173,25 +1181,25 @@ func sanitizePath(path string) string { // Use string slicing to avoid multiple allocations for { originalLen := len(path) - + // Remove leading slashes and tildes if len(path) > 0 && (path[0] == '/' || path[0] == '~') { path = path[1:] continue } - + // Remove trailing slashes if len(path) > 0 && path[len(path)-1] == '/' { path = path[:len(path)-1] continue } - + // Remove .git suffix if len(path) >= 4 && path[len(path)-4:] == ".git" { path = path[:len(path)-4] continue } - + // If no changes were made, break if len(path) == originalLen { break @@ -1231,7 +1239,7 @@ func sanitizePath(path string) string { // Returns error for better error handling instead of empty string. func getProjectDir(repository string, env Environment) (string, error) { gitProjectDir := env.Getenv("GIT_PROJECT_DIR") - + if len(gitProjectDir) > 0 && gitProjectDir[0] == '~' { homeDir, err := env.UserHomeDir() if err != nil { @@ -1248,7 +1256,7 @@ func getProjectDir(repository string, env Environment) (string, error) { // Security: Validate and clean the final path projectDir := filepath.Join(gitProjectDir, normalizedRepo) cleanedPath := filepath.Clean(projectDir) - + // Security: Ensure the path doesn't escape the base directory if gitProjectDir != "" { cleanGitProjectDir := filepath.Clean(gitProjectDir) @@ -1280,19 +1288,19 @@ func NewDirCache(config *CacheConfig, fs FileSystem) *DirCache { if config == nil { config = DefaultCacheConfig() } - + dc := &DirCache{ cache: make(map[string]cacheEntry), config: config, fs: fs, stopCleanup: make(chan struct{}), } - + // Start periodic cleanup if enabled if config.EnablePeriodicCleanup { go dc.startPeriodicCleanup() } - + return dc } @@ -1307,7 +1315,7 @@ func (dc *DirCache) Close() { func (dc *DirCache) startPeriodicCleanup() { ticker := time.NewTicker(dc.config.CleanupInterval) defer ticker.Stop() - + for { select { case <-ticker.C: @@ -1323,7 +1331,7 @@ func (dc *DirCache) cleanup() { now := time.Now() dc.mutex.Lock() defer dc.mutex.Unlock() - + evictionCount := 0 for key, entry := range dc.cache { if now.Sub(entry.timestamp) > dc.config.TTL { @@ -1331,7 +1339,7 @@ func (dc *DirCache) cleanup() { evictionCount++ } } - + dc.stats.Evictions += int64(evictionCount) dc.stats.TotalSize = int64(len(dc.cache)) } @@ -1340,7 +1348,7 @@ func (dc *DirCache) cleanup() { func (dc *DirCache) GetStats() CacheStats { dc.mutex.RLock() defer dc.mutex.RUnlock() - + stats := dc.stats stats.TotalSize = int64(len(dc.cache)) return stats @@ -1350,7 +1358,7 @@ func (dc *DirCache) GetStats() CacheStats { func (dc *DirCache) Clear() { dc.mutex.Lock() defer dc.mutex.Unlock() - + dc.cache = make(map[string]cacheEntry) dc.stats = CacheStats{} } @@ -1358,7 +1366,7 @@ func (dc *DirCache) Clear() { // IsDirectoryNotEmpty checks if the specified directory is not empty with caching. func (dc *DirCache) IsDirectoryNotEmpty(name string) bool { now := time.Now() - + // Try to get from cache first (optimistic read) dc.mutex.RLock() if entry, ok := dc.cache[name]; ok { @@ -1382,10 +1390,10 @@ func (dc *DirCache) IsDirectoryNotEmpty(name string) bool { } else { dc.mutex.RUnlock() } - + // Cache miss or expired entry - check directory exists := isDirectoryNotEmptyRaw(name, dc.fs) - + // Update cache with new entry dc.mutex.Lock() dc.cache[name] = cacheEntry{ @@ -1394,14 +1402,14 @@ func (dc *DirCache) IsDirectoryNotEmpty(name string) bool { lastAccess: now, } dc.stats.Misses++ - + // Check if cache size exceeds limit and evict LRU entries if needed if dc.config.MaxEntries > 0 && len(dc.cache) > dc.config.MaxEntries { dc.evictLRU() } - + dc.mutex.Unlock() - + return exists } @@ -1410,27 +1418,27 @@ func (dc *DirCache) evictLRU() { // Find entries to evict (remove 10% of cache when limit is exceeded) targetSize := int(float64(dc.config.MaxEntries) * 0.9) toEvict := len(dc.cache) - targetSize - + if toEvict <= 0 { return } - + // Create slice of entries with their keys for sorting type entryWithKey struct { - key string + key string lastAccess time.Time } - + entries := make([]entryWithKey, 0, len(dc.cache)) for key, entry := range dc.cache { entries = append(entries, entryWithKey{key: key, lastAccess: entry.lastAccess}) } - + // Sort by last access time (oldest first) using efficient sort.Slice sort.Slice(entries, func(i, j int) bool { return entries[i].lastAccess.Before(entries[j].lastAccess) }) - + // Remove oldest entries for i := 0; i < toEvict && i < len(entries); i++ { delete(dc.cache, entries[i].key) diff --git a/main_test.go b/main_test.go index ef7f9cd..242572e 100644 --- a/main_test.go +++ b/main_test.go @@ -75,6 +75,13 @@ func Test_getProjectDir(t *testing.T) { gitProjectDir: "src", want: "src/github.com/go-git/go-git", }, + { + name: "src", + repository: "http://zod.bitterling-ghost.ts.net/juev/test.git", + homeVar: "/home/test", + gitProjectDir: "src", + want: "src/zod.bitterling-ghost.ts.net/juev/test", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -296,7 +303,7 @@ func BenchmarkNormalizeSSH(b *testing.B) { // BenchmarkNormalizeGit benchmarks Git protocol URL parsing func BenchmarkNormalizeGit(b *testing.B) { repository := "git://github.com/user/repo.git" - // Clear cache before benchmark + // Clear cache before benchmark urlCache.Clear() b.ResetTimer() for i := 0; i < b.N; i++ { @@ -319,7 +326,7 @@ func BenchmarkNormalizeCached(b *testing.B) { func BenchmarkNormalizeMixed(b *testing.B) { repositories := []string{ "https://github.com/user/repo1.git", - "git@github.com:user/repo2.git", + "git@github.com:user/repo2.git", "git://github.com/user/repo3.git", "https://gitlab.com/user/repo4.git", "git@gitlab.com:user/repo5.git", @@ -345,7 +352,7 @@ func BenchmarkSanitizePathOptimized(b *testing.B) { func BenchmarkDetectRegexType(b *testing.B) { urls := []string{ "https://github.com/user/repo.git", - "git@github.com:user/repo.git", + "git@github.com:user/repo.git", "git://github.com/user/repo.git", "github.com/user/repo", } @@ -511,13 +518,13 @@ func TestSecureGitCloneTimeout(t *testing.T) { errorMsg string }{ { - name: "dangerous URL should fail validation", + name: "dangerous URL should fail validation", repository: "https://github.com/user/repo; rm -rf /", expectError: true, errorMsg: "security validation failed", }, { - name: "path traversal should fail validation", + name: "path traversal should fail validation", repository: "https://github.com/../../../etc/passwd", expectError: true, errorMsg: "security validation failed", @@ -908,8 +915,8 @@ func setupBenchmarkConfig(workers int) (*Config, *MockGitCloner) { } config := &Config{ - Workers: workers, - Quiet: true, + Workers: workers, + Quiet: true, RepositoryArgs: testRepos, Dependencies: &Dependencies{ FS: mockFS, @@ -930,7 +937,7 @@ func BenchmarkSequentialProcessing(b *testing.B) { // Reset mock for each iteration mockGitCloner := &MockGitCloner{ShouldFail: false} config.Dependencies.GitClone = mockGitCloner - + result := processRepositories(config) if result.ProcessedCount != 10 { b.Errorf("Expected 10 processed, got %d", result.ProcessedCount) @@ -946,7 +953,7 @@ func BenchmarkParallelProcessing(b *testing.B) { // Reset mock for each iteration mockGitCloner := &MockGitCloner{ShouldFail: false} config.Dependencies.GitClone = mockGitCloner - + result := processRepositories(config) if result.ProcessedCount != 10 { b.Errorf("Expected 10 processed, got %d", result.ProcessedCount) @@ -961,8 +968,8 @@ func BenchmarkWorkerPoolCreation(b *testing.B) { mockEnv := &DefaultEnvironment{} config := &Config{ - Workers: 4, - Quiet: true, + Workers: 4, + Quiet: true, RepositoryArgs: []string{"https://github.com/user/repo"}, Dependencies: &Dependencies{ FS: mockFS, @@ -977,4 +984,65 @@ func BenchmarkWorkerPoolCreation(b *testing.B) { wp := NewWorkerPool(config) _ = wp // Prevent compiler optimization } -} \ No newline at end of file +} + +func Test_detectRegexType(t *testing.T) { + type args struct { + repo string + } + tests := []struct { + name string + args args + want RegexType + }{ + { + name: "https", + args: args{ + repo: "https://github.com/user/repo.git", + }, + want: RegexHTTPS, + }, + { + name: "ssh", + args: args{ + repo: "git@github.com:user/repo.git", + }, + want: RegexSSH, + }, + { + name: "http protocol", + args: args{ + repo: "http://github.com/user/repo.git", + }, + want: RegexHTTPS, + }, + { + name: "git protocol", + args: args{ + repo: "git://github.com/user/repo.git", + }, + want: RegexGit, + }, + { + name: "generic format", + args: args{ + repo: "github.com/user/repo", + }, + want: RegexGeneric, + }, + { + name: "ssh with ssh prefix", + args: args{ + repo: "ssh://git@github.com:user/repo.git", + }, + want: RegexSSH, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := detectRegexType(tt.args.repo); got != tt.want { + t.Errorf("detectRegexType() = %v, want %v", got, tt.want) + } + }) + } +} From 84f171a4c3b803e645a35a8dd9f1ed2b0a63cf12 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Sun, 7 Sep 2025 09:53:41 +0300 Subject: [PATCH 2/5] feat: enhance SSH URL handling with optional port support and improve validation checks --- main.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 2542ab5..fee4277 100644 --- a/main.go +++ b/main.go @@ -468,6 +468,7 @@ var ( } // SSH URLs (git@github.com:user/repo.git or ssh://user@host:port/path) + // Handles both SSH URL formats with optional port support sshRegexPool = sync.Pool{ New: func() any { return regexp.MustCompile(`^(?:ssh://)?([^@]+)@([^/:]+)(?::(\d+))?[:/](.+?)(?:\.git)?/?$`) @@ -1064,11 +1065,15 @@ func normalize(repo string) (string, error) { if len(match) != 3 { return "", errors.New("failed to parse HTTPS/Git repository URL format") } + // Validate array bounds before access + if len(match) <= 2 { + return "", errors.New("HTTPS/Git repository URL match does not contain expected groups") + } host, path = match[1], match[2] case RegexSSH: match := r.FindStringSubmatch(repo) - if len(match) < 4 { + if len(match) < 5 { return "", errors.New("failed to parse SSH repository URL format") } // match[1] = user, match[2] = host, match[3] = port (optional), match[4] = path @@ -1080,6 +1085,10 @@ func normalize(repo string) (string, error) { if len(match) != 3 { return "", errors.New("failed to parse repository URL format") } + // Validate array bounds before access + if len(match) <= 2 { + return "", errors.New("Generic repository URL match does not contain expected groups") + } host, path = match[1], match[2] } From 28bd2c06c852279b00783fea899ac2b0b4a8cdf3 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Sun, 7 Sep 2025 10:02:53 +0300 Subject: [PATCH 3/5] fix: standardize error message casing in URL normalization --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index fee4277..2f913b8 100644 --- a/main.go +++ b/main.go @@ -1087,7 +1087,7 @@ func normalize(repo string) (string, error) { } // Validate array bounds before access if len(match) <= 2 { - return "", errors.New("Generic repository URL match does not contain expected groups") + return "", errors.New("generic repository URL match does not contain expected groups") } host, path = match[1], match[2] } From 6acc7aaae1f0f8d06406021e3f0aaed89ca7af09 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Sun, 7 Sep 2025 10:11:43 +0300 Subject: [PATCH 4/5] fix: correct SSH URL format in test case for regex detection --- main.go | 8 -------- main_test.go | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/main.go b/main.go index 2f913b8..95d05b9 100644 --- a/main.go +++ b/main.go @@ -1065,10 +1065,6 @@ func normalize(repo string) (string, error) { if len(match) != 3 { return "", errors.New("failed to parse HTTPS/Git repository URL format") } - // Validate array bounds before access - if len(match) <= 2 { - return "", errors.New("HTTPS/Git repository URL match does not contain expected groups") - } host, path = match[1], match[2] case RegexSSH: @@ -1085,10 +1081,6 @@ func normalize(repo string) (string, error) { if len(match) != 3 { return "", errors.New("failed to parse repository URL format") } - // Validate array bounds before access - if len(match) <= 2 { - return "", errors.New("generic repository URL match does not contain expected groups") - } host, path = match[1], match[2] } diff --git a/main_test.go b/main_test.go index 242572e..222a8ae 100644 --- a/main_test.go +++ b/main_test.go @@ -1033,7 +1033,7 @@ func Test_detectRegexType(t *testing.T) { { name: "ssh with ssh prefix", args: args{ - repo: "ssh://git@github.com:user/repo.git", + repo: "ssh://git@github.com/user/repo.git", }, want: RegexSSH, }, From 30824700494da448da50ee00bf296c8e00179bb6 Mon Sep 17 00:00:00 2001 From: Evsyukov Denis Date: Sun, 7 Sep 2025 10:16:06 +0300 Subject: [PATCH 5/5] fix: update SSH URL parsing to require exactly five components --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 95d05b9..7b7ca71 100644 --- a/main.go +++ b/main.go @@ -1069,7 +1069,7 @@ func normalize(repo string) (string, error) { case RegexSSH: match := r.FindStringSubmatch(repo) - if len(match) < 5 { + if len(match) != 5 { return "", errors.New("failed to parse SSH repository URL format") } // match[1] = user, match[2] = host, match[3] = port (optional), match[4] = path