Skip to content

Commit e745f39

Browse files
committed
feat(nuget): add source preflight and fast-fail
Refs: SCA-296
1 parent 2d79d05 commit e745f39

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

module/nuget/nuget_cmd_build.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ package nuget
33
import (
44
"bufio"
55
"context"
6+
"crypto/tls"
67
"encoding/base64"
78
"encoding/json"
9+
"encoding/xml"
810
"errors"
911
"fmt"
1012
"io"
13+
"net/http"
14+
neturl "net/url"
1115
"os"
1216
"os/exec"
1317
"path/filepath"
@@ -33,8 +37,30 @@ var _ErrDotnetNotFound = errors.New("dotnet not found")
3337
const (
3438
nugetBuildMaxTimeout = 6 * time.Hour
3539
nugetCommandIdleTimeout = 30 * time.Second
40+
nugetPreflightTimeout = 8 * time.Second
3641
)
3742

43+
type nugetConfigXML struct {
44+
PackageSources nugetPackageSourcesXML `xml:"packageSources"`
45+
}
46+
47+
type nugetPackageSourcesXML struct {
48+
Clear *struct{} `xml:"clear"`
49+
Add []nugetSourceAddXML `xml:"add"`
50+
}
51+
52+
type nugetSourceAddXML struct {
53+
Value string `xml:"value,attr"`
54+
}
55+
56+
type nugetSourceProbeResult struct {
57+
Source string
58+
Reachable bool
59+
StatusCode int
60+
Err error
61+
Duration time.Duration
62+
}
63+
3864
func tailText(s string, max int) string {
3965
s = strings.TrimSpace(s)
4066
if s == "" || max <= 0 || len(s) <= max {
@@ -119,6 +145,153 @@ func logNugetToolVersion(ctx context.Context, logger *zap.Logger) {
119145
logger.Sugar().Infof("NuGet detected: path=%s version=%s", dotnetPath, strings.TrimSpace(string(data)))
120146
}
121147

148+
func collectNugetSources(solutionPath string) ([]string, error) {
149+
paths := detectNugetConfigPaths(solutionPath)
150+
existing := make([]string, 0, len(paths))
151+
for _, p := range paths {
152+
if _, err := os.Stat(p); err == nil {
153+
existing = append(existing, p)
154+
}
155+
}
156+
// Apply low-priority first, then high-priority overrides.
157+
for i, j := 0, len(existing)-1; i < j; i, j = i+1, j-1 {
158+
existing[i], existing[j] = existing[j], existing[i]
159+
}
160+
161+
sources := make([]string, 0)
162+
seen := map[string]struct{}{}
163+
for _, p := range existing {
164+
items, hasClear, err := parseNugetSourcesFromConfig(p)
165+
if err != nil {
166+
return nil, fmt.Errorf("parse NuGet config failed (%s): %w", p, err)
167+
}
168+
if hasClear {
169+
sources = sources[:0]
170+
seen = map[string]struct{}{}
171+
}
172+
for _, s := range items {
173+
if _, ok := seen[s]; ok {
174+
continue
175+
}
176+
seen[s] = struct{}{}
177+
sources = append(sources, s)
178+
}
179+
}
180+
return sources, nil
181+
}
182+
183+
func parseNugetSourcesFromConfig(path string) ([]string, bool, error) {
184+
data, err := os.ReadFile(path)
185+
if err != nil {
186+
return nil, false, err
187+
}
188+
var cfg nugetConfigXML
189+
if err = xml.Unmarshal(data, &cfg); err != nil {
190+
return nil, false, err
191+
}
192+
out := make([]string, 0, len(cfg.PackageSources.Add))
193+
for _, item := range cfg.PackageSources.Add {
194+
v := strings.TrimSpace(item.Value)
195+
if v != "" {
196+
out = append(out, v)
197+
}
198+
}
199+
return out, cfg.PackageSources.Clear != nil, nil
200+
}
201+
202+
func maskURLCredential(raw string) string {
203+
u, err := neturl.Parse(raw)
204+
if err != nil {
205+
return raw
206+
}
207+
if u.User != nil {
208+
user := u.User.Username()
209+
if user != "" {
210+
u.User = neturl.UserPassword(user, "******")
211+
}
212+
}
213+
return u.String()
214+
}
215+
216+
func probeNugetSource(ctx context.Context, source string) nugetSourceProbeResult {
217+
start := time.Now()
218+
res := nugetSourceProbeResult{Source: source}
219+
reqCtx, cancel := context.WithTimeout(ctx, nugetPreflightTimeout)
220+
defer cancel()
221+
222+
transport := &http.Transport{}
223+
if os.Getenv("TLS_ALLOW_INSECURE") == "1" {
224+
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
225+
}
226+
client := &http.Client{
227+
Transport: transport,
228+
Timeout: nugetPreflightTimeout,
229+
}
230+
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, source, nil)
231+
if err != nil {
232+
res.Err = err
233+
res.Duration = time.Since(start)
234+
return res
235+
}
236+
resp, err := client.Do(req)
237+
if err != nil {
238+
res.Err = err
239+
res.Duration = time.Since(start)
240+
return res
241+
}
242+
defer resp.Body.Close()
243+
_, _ = io.CopyN(io.Discard, resp.Body, 1024)
244+
res.StatusCode = resp.StatusCode
245+
res.Duration = time.Since(start)
246+
res.Reachable = (resp.StatusCode >= 200 && resp.StatusCode < 400) || resp.StatusCode == 401 || resp.StatusCode == 403
247+
return res
248+
}
249+
250+
func preflightNugetSources(ctx context.Context, logger *zap.Logger, solutionPath string) error {
251+
sources, err := collectNugetSources(solutionPath)
252+
if err != nil {
253+
logger.Sugar().Warnf("NuGet source preflight parse failed, skip fast-fail: %v", err)
254+
return nil
255+
}
256+
if len(sources) == 0 {
257+
logger.Warn("NuGet source preflight skipped: no package sources found")
258+
return nil
259+
}
260+
logger.Sugar().Infof("NuGet source preflight start: total=%d", len(sources))
261+
results := make([]nugetSourceProbeResult, 0, len(sources))
262+
reachable := 0
263+
for _, s := range sources {
264+
r := probeNugetSource(ctx, s)
265+
results = append(results, r)
266+
if r.Reachable {
267+
reachable++
268+
logger.Sugar().Infof("NuGet source preflight ok: source=%s status=%d cost=%s",
269+
maskURLCredential(s), r.StatusCode, r.Duration.Round(time.Millisecond))
270+
continue
271+
}
272+
if r.Err != nil {
273+
logger.Sugar().Warnf("NuGet source preflight failed: source=%s err=%v cost=%s",
274+
maskURLCredential(s), r.Err, r.Duration.Round(time.Millisecond))
275+
} else {
276+
logger.Sugar().Warnf("NuGet source preflight failed: source=%s status=%d cost=%s",
277+
maskURLCredential(s), r.StatusCode, r.Duration.Round(time.Millisecond))
278+
}
279+
}
280+
if reachable > 0 {
281+
logger.Sugar().Infof("NuGet source preflight completed: reachable=%d/%d", reachable, len(sources))
282+
return nil
283+
}
284+
parts := make([]string, 0, len(results))
285+
for _, r := range results {
286+
if r.Err != nil {
287+
parts = append(parts, fmt.Sprintf("%s err=%v", maskURLCredential(r.Source), r.Err))
288+
} else {
289+
parts = append(parts, fmt.Sprintf("%s status=%d", maskURLCredential(r.Source), r.StatusCode))
290+
}
291+
}
292+
return fmt.Errorf("NuGet source preflight failed: all sources unreachable (%d). details: %s", len(results), strings.Join(parts, "; "))
293+
}
294+
122295
func multipleBuilds(ctx context.Context, task *model.InspectionTask) error {
123296
logger := logctx.Use(ctx)
124297
slnPaths, err := findCLNList(task.Dir())
@@ -419,6 +592,9 @@ func listNuget(ctx context.Context, task *model.InspectionTask, solutionPath str
419592
var logger = logctx.Use(ctx)
420593
logNugetToolVersion(ctx, logger)
421594
logNugetRemoteConfigPaths(logger, solutionPath)
595+
if err = preflightNugetSources(ctx, logger, solutionPath); err != nil {
596+
return err
597+
}
422598
err = buildPackage(ctx, logger, solutionPath)
423599
if err != nil {
424600
return

0 commit comments

Comments
 (0)