• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 16105711247

07 Jul 2025 01:48AM UTC coverage: 47.797% (+0.2%) from 47.637%
16105711247

push

github

HebaruSan
Merge #4404 Refactor Net.Download exception handling

3898 of 8735 branches covered (44.63%)

Branch coverage included in aggregate %.

4 of 27 new or added lines in 4 files covered. (14.81%)

7 existing lines in 2 files now uncovered.

8362 of 16915 relevant lines covered (49.44%)

1.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.68
/Core/Net/Net.cs
1
using System;
2
using System.IO;
3
using System.Collections.Generic;
4
using System.Linq;
5
using System.Net;
6
using System.Text.RegularExpressions;
7
using System.Threading;
8

9
using Autofac;
10
using ChinhDo.Transactions.FileManager;
11
using log4net;
12

13
using CKAN.Extensions;
14
using CKAN.Configuration;
15

16
namespace CKAN
17
{
18
    /// <summary>
19
    /// Doing something with the network? Do it here.
20
    /// </summary>
21

22
    public static class Net
23
    {
24
        // The user agent that we report to web sites
25
        // Maybe overwritten by command line args
26
        public static readonly string UserAgentString = $"Mozilla/5.0 (compatible; CKAN/{Meta.ReleaseVersion})";
2✔
27

28
        private const int MaxRetries             = 3;
29
        private const int RetryDelayMilliseconds = 100;
30

31
        private static readonly ILog log = LogManager.GetLogger(typeof(Net));
2✔
32

33
        public static readonly Dictionary<string, Uri> ThrottledHosts = new Dictionary<string, Uri>()
2✔
34
        {
35
            {
36
                "api.github.com",
37
                new Uri(HelpURLs.AuthTokens)
38
            }
39
        };
40

41
        /// <summary>
42
        /// Make a HEAD request to get the ETag of a URL without downloading it
43
        /// </summary>
44
        /// <param name="url">Remote URL to check</param>
45
        /// <returns>
46
        /// ETag value of the URL if any, otherwise null, see
47
        /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
48
        /// </returns>
49
        public static string? CurrentETag(Uri url)
50
        {
2✔
51
            // HttpClient apparently is worse than what it was supposed to replace
52
            #pragma warning disable SYSLIB0014
53
            WebRequest req = WebRequest.Create(url);
2✔
54
            #pragma warning restore SYSLIB0014
55
            req.Method = "HEAD";
2✔
56
            try
57
            {
2✔
58
                HttpWebResponse resp = (HttpWebResponse)req.GetResponse();
2✔
59
                var val = resp.Headers["ETag"]?.Replace("\"", "");
2✔
60
                resp.Close();
2✔
61
                return val;
2✔
62
            }
63
            catch (WebException exc)
×
64
            {
×
65
                // Let the calling code keep going to get the actual problem
66
                log.Debug($"Failed to get ETag from {url}", exc);
×
67
                return null;
×
68
            }
69
        }
2✔
70

71
        /// <summary>
72
        /// Downloads the specified url, and stores it in the filename given.
73
        /// If no filename is supplied, a temporary file will be generated.
74
        /// Returns the filename the file was saved to on success.
75
        /// Throws an exception on failure.
76
        /// Throws a MissingCertificateException *and* prints a message to the
77
        /// console if we detect missing certificates (common on a fresh Linux/mono install)
78
        /// </summary>
79
        public static string Download(Uri         url,
80
                                      out string? etag,
81
                                      string?     userAgent = null,
82
                                      string?     filename  = null,
83
                                      IUser?      user      = null)
84
        {
2✔
85
            user?.RaiseMessage(Properties.Resources.NetDownloading, url);
2!
86
            var FileTransaction = new TxFileManager();
2✔
87

88
            // Generate a temporary file if none is provided.
89
            filename ??= FileTransaction.GetTempFileName();
2✔
90

91
            log.DebugFormat("Downloading {0} to {1}", url, filename);
2✔
92

93
            try
94
            {
2✔
95
                // This WebClient child class does some complicated stuff, let's keep using it for now
96
                #pragma warning disable SYSLIB0014
97
                var agent = new RedirectingTimeoutWebClient(userAgent ?? UserAgentString);
2✔
98
                #pragma warning restore SYSLIB0014
99
                agent.DownloadFile(url, filename);
2✔
100
                etag = agent.ResponseHeaders?.Get("ETag")?.Replace("\"", "");
2✔
101
            }
2!
102
            catch (WebException wex)
NEW
103
            when (wex is
×
104
                  {
105
                      Status:   WebExceptionStatus.ProtocolError,
106
                      Response: HttpWebResponse
107
                                {
108
                                    StatusCode: HttpStatusCode.Redirect
109
                                }
110
                                response,
111
                  })
UNCOV
112
            {
×
113
                // Get redirect if redirected.
114
                // This is needed when redirecting from HTTPS to HTTP on .NET Core.
NEW
115
                var loc = new Uri(response.GetResponseHeader("Location"));
×
NEW
116
                log.InfoFormat("Redirected to {0}", loc);
×
NEW
117
                return Download(loc, out etag, userAgent, filename, user);
×
118
            }
119
            catch (WebException wex)
NEW
120
            when (wex is { Status: WebExceptionStatus.SecureChannelFailure })
×
NEW
121
            {
×
NEW
122
                throw new MissingCertificateKraken(url, null, wex);
×
123
            }
124
            catch (WebException wex)
NEW
125
            when (wex is { InnerException: Exception inner })
×
NEW
126
            {
×
NEW
127
                throw new DownloadErrorsKraken(new NetAsyncDownloader.DownloadTargetFile(url),
×
128
                                               inner);
129
            }
130
            // Otherwise it's a valid failure from the server (probably a 404), keep it
NEW
131
            catch
×
NEW
132
            {
×
133
                try
UNCOV
134
                {
×
135
                    // Clean up our file, it's unlikely to be complete.
136
                    // We do this even though we're using transactional files, as we may not be in a transaction.
UNCOV
137
                    log.DebugFormat("Removing {0} after web error failure", filename);
×
UNCOV
138
                    FileTransaction.Delete(filename);
×
UNCOV
139
                }
×
140
                catch
×
141
                {
×
142
                    // It's okay if this fails.
143
                }
×
UNCOV
144
                throw;
×
145
            }
146

147
            return filename;
2✔
148
        }
2✔
149

150
        /// <summary>
151
        /// Download a string from a URL
152
        /// </summary>
153
        /// <param name="url">URL to download from</param>
154
        /// <param name="userAgent">User agent to send with the request</param>
155
        /// <param name="authToken">An authentication token sent with the "Authorization" header.
156
        ///                         Attempted to be looked up from the configuraiton if not specified</param>
157
        /// <param name="mimeType">A mime type sent with the "Accept" header</param>
158
        /// <param name="timeout">Timeout for the request in milliseconds, defaulting to 100 000 (=100 seconds)</param>
159
        /// <returns>The text content returned by the server</returns>
160
        public static string? DownloadText(Uri     url,
161
                                           string? userAgent = null,
162
                                           string? authToken = "",
163
                                           string? mimeType = null,
164
                                           int     timeout = 100000)
165
        {
2✔
166
            log.DebugFormat("About to download {0}", url.OriginalString);
2✔
167

168
            #pragma warning disable SYSLIB0014
169
            WebClient agent = new RedirectingTimeoutWebClient(userAgent ?? UserAgentString,
2✔
170
                                                              timeout, mimeType ?? "");
171
            #pragma warning restore SYSLIB0014
172

173
            // Check whether to use an auth token for this host
174
            if (!string.IsNullOrEmpty(authToken)
2!
175
                || (ServiceLocator.Container.Resolve<IConfiguration>().TryGetAuthToken(url.Host, out authToken)
176
                    && !string.IsNullOrEmpty(authToken)))
177
            {
×
178
                log.InfoFormat("Using auth token for {0}", url.Host);
×
179
                // Send our auth token to the GitHub API (or whoever else needs one)
180
                agent.Headers.Add("Authorization", $"token {authToken}");
×
181
            }
×
182

183
            for (int whichAttempt = 0; whichAttempt < MaxRetries + 1; ++whichAttempt)
3!
184
            {
2✔
185
                try
186
                {
2✔
187
                    var content = agent.DownloadString(url.OriginalString);
2✔
188
                    var header  = agent.ResponseHeaders?.ToString();
2✔
189

190
                    log.DebugFormat("Response from {0}:\r\n\r\n{1}\r\n{2}", url, header, content);
2✔
191

192
                    return content;
2!
193
                }
194
                catch (WebException wex)
NEW
195
                when (wex.Status == WebExceptionStatus.Timeout)
×
NEW
196
                {
×
NEW
197
                    throw new RequestTimedOutKraken(url, wex);
×
198
                }
199
                catch (WebException wex)
NEW
200
                when (wex.Status != WebExceptionStatus.ProtocolError
×
201
                      && whichAttempt < MaxRetries)
202
                {
×
203
                    log.DebugFormat("Web request failed with non-protocol error, retrying in {0} milliseconds: {1}", RetryDelayMilliseconds * whichAttempt, wex.Message);
×
204
                    Thread.Sleep(RetryDelayMilliseconds * whichAttempt);
×
205
                }
×
206
            }
×
207
            // Should never get here, because we don't catch any exceptions
208
            // in the final iteration of the above for loop. They should be
209
            // thrown to the calling code, or the call should succeed.
210
            return null;
×
211
        }
2✔
212

213
        public static Uri? ResolveRedirect(Uri     url,
214
                                           string? userAgent,
215
                                           int     maxRedirects = 6)
216
        {
2✔
217
            var urls = url.TraverseNodes(u => new RedirectWebClient(userAgent ?? UserAgentString) is RedirectWebClient rwClient
2!
218
                                              && rwClient.OpenRead(u) is Stream s && DisposeStream(s)
219
                                              && rwClient.ResponseHeaders is WebHeaderCollection headers
220
                                              && headers["Location"] is string location
221
                                                  ? Uri.IsWellFormedUriString(location, UriKind.Absolute)
222
                                                      ? new Uri(location)
223
                                                      : Uri.IsWellFormedUriString(location, UriKind.Relative)
224
                                                          ? new Uri(u, location)
225
                                                          : throw new Kraken(string.Format(Properties.Resources.NetInvalidLocation,
226
                                                                                           location))
227
                                                  : null)
228
                          // The first element is the input, so e.g. if we want two redirects, that's three elements
229
                          .Take(maxRedirects + 1)
230
                          .ToArray();
231
            if (log.IsDebugEnabled)
2!
232
            {
×
233
                foreach ((Uri from, Uri to) in urls.Zip(urls.Skip(1)))
×
234
                {
×
235
                    log.DebugFormat("Redirected {0} to {1}", from, to);
×
236
                }
×
237
            }
×
238
            return urls.LastOrDefault();
2✔
239
        }
2✔
240

241
        private static bool DisposeStream(Stream s)
242
        {
2✔
243
            s.Dispose();
2✔
244
            return true;
2✔
245
        }
2✔
246

247
        /// <summary>
248
        /// Provide an escaped version of the given Uri string, including converting
249
        /// square brackets to their escaped forms.
250
        /// </summary>
251
        /// <returns>
252
        /// <c>null</c> if the string is not a valid <see cref="Uri"/>, otherwise its normalized form.
253
        /// </returns>
254
        public static string? NormalizeUri(string uri)
255
        {
2✔
256
            // Uri.EscapeUriString has been deprecated because its purpose was ambiguous.
257
            // Is it supposed to turn a "&" into part of the content of a form field,
258
            // or is it supposed to assume that it separates different form fields?
259
            // https://github.com/dotnet/runtime/issues/31387
260
            // So now we have to just substitute certain characters ourselves one by one.
261

262
            // Square brackets are "reserved characters" that should not appear
263
            // in strings to begin with, so C# doesn't try to escape them in case
264
            // they're being used in a special way. They're not; some mod authors
265
            // just have crazy ideas as to what should be in a URL, and SD doesn't
266
            // escape them in its API. There's probably more in RFC 3986.
267
            var escaped = UriEscapeAll(uri.Replace(" ", "+"),
2✔
268
                                       '"', '<', '>', '^', '`',
269
                                       '{', '|', '}', '[', ']');
270

271
            // Make sure we have a "http://" or "https://" start.
272
            if (!Regex.IsMatch(escaped, "(?i)^(http|https)://"))
2✔
273
            {
2✔
274
                // Prepend "http://", as we do not know if the site supports https.
275
                escaped = "http://" + escaped;
2✔
276
            }
2✔
277

278
            return Uri.IsWellFormedUriString(escaped, UriKind.Absolute)
2✔
279
                       ? escaped
280
                       : null;
281
        }
2✔
282

283
        private static string UriEscapeAll(string orig, params char[] characters)
284
            => characters.Aggregate(orig,
2✔
285
                                    (s, c) => s.Replace(c.ToString(),
2✔
286
                                                        Uri.HexEscape(c)));
287

288
        /// <summary>
289
        /// Translate a URL into a form that returns the raw contents of a file
290
        /// Only changes GitHub URLs, others are left as-is
291
        /// </summary>
292
        /// <param name="remoteUri">URL to handle</param>
293
        /// <returns>
294
        /// URL pointing to the raw contents of the input URL
295
        /// </returns>
296
        public static Uri GetRawUri(Uri remoteUri)
297
        {
2✔
298
            // Authors may use the URI of the GitHub file page instead of the URL to the actual raw file.
299
            // Detect that case and automatically transform the remote URL to one we can use.
300
            // This is hacky and fragile but it's basically what KSP-AVC itself does in its
301
            // FormatCompatibleUrl(string) method so we have to go along with the flow:
302
            // https://github.com/CYBUTEK/KSPAddonVersionChecker/blob/ff94000144a666c8ff637c71b802e1baee9c15cd/KSP-AVC/AddonInfo.cs#L199
303
            // However, this implementation is more robust as it actually parses the URI rather than doing
304
            // basic string replacements.
305
            if (string.Compare(remoteUri.Host, "github.com", StringComparison.OrdinalIgnoreCase) == 0)
2✔
306
            {
2✔
307
                // We expect a non-raw URI to be in one of these forms:
308
                //  1. https://github.com/<USER>/<PROJECT>/blob/<BRANCH>/<PATH>
309
                //  2. https://github.com/<USER>/<PROJECT>/tree/<BRANCH>/<PATH>
310
                //
311
                // Therefore, we expect at least six segments in the path:
312
                //  1. "/"
313
                //  2. "<USER>/"
314
                //  3. "<PROJECT>/"
315
                //  4. "blob/" or "tree/"
316
                //  5. "<BRANCH>/"
317
                //  6+. "<PATH>"
318
                //
319
                // And that the fourth segment (index 3) is either "blob/" or "tree/"
320

321
                // Check that the path is what we expect
322
                var segments = remoteUri.Segments.ToList();
2✔
323

324
                if (//segments is [_, _, _, "raw/", ..]
2✔
325
                    segments.Count > 3
326
                    && segments[3] is "raw/")
327
                {
2✔
328
                    log.InfoFormat("Remote GitHub URL is in raw format, using as is.");
2✔
329
                    return remoteUri;
2✔
330
                }
331
                if (//segments is [_, _, _, "releases/", "latest/", "download/", ..]
2!
332
                    segments.Count > 6
333
                    && segments[3] is "releases/"
334
                    && segments[4] is "latest/"
335
                    && segments[5] is "download/")
336
                {
×
337
                    log.InfoFormat("Remote GitHub URL is in release asset format, using as is.");
×
338
                    return remoteUri;
×
339
                }
340
                if (//segments is not [_, _, _, "blob/" or "tree/", _, _, ..]
2✔
341
                    segments.Count < 6
342
                    || segments[3] is not ("blob/" or "tree/"))
343
                {
2✔
344
                    log.WarnFormat("Remote non-raw GitHub URL is in an unknown format, using as is.");
2✔
345
                    return remoteUri;
2✔
346
                }
347

348
                var remoteUriBuilder = new UriBuilder(remoteUri)
2✔
349
                {
350
                    // Replace host with raw host
351
                    Host = "raw.githubusercontent.com",
352
                    // Remove "blob/" or "tree/" segment from raw URI
353
                    Path = string.Join("", segments.Take(3)
354
                                                   .Concat(segments.Skip(4))),
355
                };
356

357
                log.InfoFormat("Canonicalized non-raw GitHub URL to: {0}",
2✔
358
                               remoteUriBuilder.Uri);
359

360
                return remoteUriBuilder.Uri;
2✔
361
            }
362
            else
363
            {
2✔
364
                return remoteUri;
2✔
365
            }
366
        }
2✔
367
    }
368
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc