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

KSP-CKAN / CKAN / 20049808009

09 Dec 2025 02:28AM UTC coverage: 85.3% (-0.04%) from 85.335%
20049808009

push

github

HebaruSan
Merge #4469 Refactor module installer to use relative paths internally

1998 of 2162 branches covered (92.41%)

Branch coverage included in aggregate %.

87 of 98 new or added lines in 10 files covered. (88.78%)

1 existing line in 1 file now uncovered.

11928 of 14164 relevant lines covered (84.21%)

1.76 hits per line

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

80.93
/Core/Types/ModuleInstallDescriptor.cs
1
using System;
2
using System.IO;
3
using System.ComponentModel;
4
using System.Collections.Generic;
5
using System.Linq;
6
using System.Runtime.Serialization;
7
using System.Text.RegularExpressions;
8
using System.Runtime.CompilerServices;
9
using System.Reflection;
10

11
using ICSharpCode.SharpZipLib.Zip;
12
using Newtonsoft.Json;
13

14
using CKAN.IO;
15
using CKAN.Games;
16

17
[assembly: InternalsVisibleTo("CKAN.Tests")]
18

19
namespace CKAN
20
{
21
    [JsonObject(MemberSerialization.OptIn)]
22
    public class ModuleInstallDescriptor : IEquatable<ModuleInstallDescriptor>
23
    {
24

25
        #region Properties
26

27
        // Either file, find, or find_regexp is required, we check this manually at deserialise.
28
        [JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)]
29
        public string? file;
30

31
        [JsonProperty("find", NullValueHandling = NullValueHandling.Ignore)]
32
        public string? find;
33

34
        [JsonProperty("find_regexp", NullValueHandling = NullValueHandling.Ignore)]
35
        public string? find_regexp;
36

37
        [JsonProperty("find_matches_files", DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
38
        [DefaultValue(false)]
39
        public bool find_matches_files = false;
2✔
40

41
        [JsonProperty("install_to", DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
42
        [DefaultValue("GameData")]
43
        public string? install_to;
44

45
        [JsonProperty("as", NullValueHandling = NullValueHandling.Ignore)]
46
        public string? @as;
47

48
        [JsonProperty("filter", NullValueHandling = NullValueHandling.Ignore)]
49
        [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
50
        public List<string>? filter;
51

52
        [JsonProperty("filter_regexp", NullValueHandling = NullValueHandling.Ignore)]
53
        [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
54
        public List<string>? filter_regexp;
55

56
        [JsonProperty("include_only", NullValueHandling = NullValueHandling.Ignore)]
57
        [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
58
        public List<string>? include_only;
59

60
        [JsonProperty("include_only_regexp", NullValueHandling = NullValueHandling.Ignore)]
61
        [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
62
        public List<string>? include_only_regexp;
63

64
        [JsonIgnore]
65
        private Regex? inst_pattern = null;
2✔
66

67
        [JsonIgnore]
68
        private Regex InstallPattern
69
            => inst_pattern ??= new Regex(
2✔
NEW
70
                   this switch
×
71
                   {
72
                       { file:        string f } => $"^{Regex.Escape(CKANPathUtils.NormalizePath(f))}(/|$)",
2✔
73
                       { find:        string f } => $"(?:^|/){Regex.Escape(CKANPathUtils.NormalizePath(f))}(/|$)",
2✔
NEW
74
                       { find_regexp: string f } => f,
×
NEW
75
                       _ => throw new Kraken(Properties.Resources.ModuleInstallDescriptorRequireFileFind),
×
76
                   },
77
                   RegexOptions.IgnoreCase | RegexOptions.Compiled);
78

79
        private static readonly Regex trailingSlashPattern = new Regex("/$", RegexOptions.Compiled);
2✔
80

81
        [OnDeserialized]
82
        internal void DeSerialisationFixes(StreamingContext like_i_could_care)
83
        {
2✔
84
            // Make sure our install_to fields exists. We may be able to remove
85
            // this check now that we're doing better json-fu above.
86
            if (install_to == null)
2✔
87
            {
×
88
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorMustHaveInstallTo);
×
89
            }
90

91
            var setCount = new[] { file, find, find_regexp }.Count(i => i != null);
2✔
92

93
            // Make sure we have either a `file`, `find`, or `find_regexp` stanza.
94
            if (setCount == 0)
2✔
95
            {
2✔
96
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorRequireFileFind);
2✔
97
            }
98

99
            if (setCount > 1)
2✔
100
            {
×
101
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorTooManyFileFind);
×
102
            }
103

104
            // Make sure only filter or include_only fields exist but not both at the same time
105
            var filterCount = new[] { filter, filter_regexp }.Count(i => i != null);
2✔
106
            var includeOnlyCount = new[] { include_only, include_only_regexp }.Count(i => i != null);
2✔
107

108
            if (filterCount > 0 && includeOnlyCount > 0)
2✔
109
            {
×
110
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorTooManyFilterInclude);
×
111
            }
112

113
            // Normalize paths on load (note, doesn't cover assignment like in tests)
114
            install_to = CKANPathUtils.NormalizePath(install_to);
2✔
115
        }
2✔
116

117
        #endregion
118

119
        #region Constructors
120

121
        [JsonConstructor]
122
        private ModuleInstallDescriptor()
2✔
123
        {
2✔
124
            install_to = typeof(ModuleInstallDescriptor).GetTypeInfo()
2✔
125
                                                        ?.GetDeclaredField("install_to")
126
                                                        ?.GetCustomAttribute<DefaultValueAttribute>()
127
                                                        ?.Value
128
                                                        ?.ToString();
129
        }
2✔
130

131
        /// <summary>
132
        /// Returns a default install stanza for the identifier provided.
133
        /// </summary>
134
        /// <returns>
135
        /// { "find": "ident", "install_to": "GameData" }
136
        /// </returns>
137
        public static ModuleInstallDescriptor DefaultInstallStanza(IGame game, string ident)
138
            => new ModuleInstallDescriptor()
2✔
139
               {
140
                   find       = ident,
141
                   install_to = game.PrimaryModDirectoryRelative,
142
               };
143

144
        #endregion
145

146
        /// <summary>
147
        /// Compare two install stanzas
148
        /// </summary>
149
        /// <param name="other">The other stanza for comparison</param>
150
        /// <returns>
151
        /// True if they're equivalent, false if they're different.
152
        /// </returns>
153
        public override bool Equals(object? other)
154
            => Equals(other as ModuleInstallDescriptor);
2✔
155

156
        /// <summary>
157
        /// Compare two install stanzas
158
        /// </summary>
159
        /// <param name="otherStanza">The other stanza for comparison</param>
160
        /// <returns>
161
        /// True if they're equivalent, false if they're different.
162
        /// IEquatable&lt;&gt; uses this for more efficient comparisons.
163
        /// </returns>
164
        public bool Equals(ModuleInstallDescriptor? otherStanza)
165
        {
2✔
166
            if (otherStanza == null)
2✔
167
            {
2✔
168
                // Not even the right type!
169
                return false;
2✔
170
            }
171

172
            if (CKANPathUtils.NormalizePath(file ?? "") != CKANPathUtils.NormalizePath(otherStanza.file ?? ""))
2✔
173
            {
×
174
                return false;
×
175
            }
176

177
            if (CKANPathUtils.NormalizePath(find ?? "") != CKANPathUtils.NormalizePath(otherStanza.find ?? ""))
2✔
178
            {
×
179
                return false;
×
180
            }
181

182
            if (find_regexp != otherStanza.find_regexp)
2✔
183
            {
×
184
                return false;
×
185
            }
186

187
            if (CKANPathUtils.NormalizePath(install_to ?? "") != CKANPathUtils.NormalizePath(otherStanza.install_to ?? ""))
2✔
188
            {
×
189
                return false;
×
190
            }
191

192
            if (@as != otherStanza.@as)
2✔
193
            {
×
194
                return false;
×
195
            }
196

197
            if ((filter == null) != (otherStanza.filter == null))
2✔
198
            {
×
199
                return false;
×
200
            }
201

202
            if (filter != null && otherStanza.filter != null
2✔
203
                && !filter.SequenceEqual(otherStanza.filter))
204
            {
×
205
                return false;
×
206
            }
207

208
            if ((filter_regexp == null) != (otherStanza.filter_regexp == null))
2✔
209
            {
×
210
                return false;
×
211
            }
212

213
            if (filter_regexp != null && otherStanza.filter_regexp != null
2✔
214
                && !filter_regexp.SequenceEqual(otherStanza.filter_regexp))
215
            {
×
216
                return false;
×
217
            }
218

219
            if (find_matches_files != otherStanza.find_matches_files)
2✔
220
            {
×
221
                return false;
×
222
            }
223

224
            if ((include_only == null) != (otherStanza.include_only == null))
2✔
225
            {
×
226
                return false;
×
227
            }
228

229
            if (include_only != null && otherStanza.include_only != null
2✔
230
                && !include_only.SequenceEqual(otherStanza.include_only))
231
            {
×
232
                return false;
×
233
            }
234

235
            if ((include_only_regexp == null) != (otherStanza.include_only_regexp == null))
2✔
236
            {
×
237
                return false;
×
238
            }
239

240
            if (include_only_regexp != null && otherStanza.include_only_regexp != null
2✔
241
                && !include_only_regexp.SequenceEqual(otherStanza.include_only_regexp))
242
            {
×
243
                return false;
×
244
            }
245

246
            return true;
2✔
247
        }
2✔
248

249
        public override int GetHashCode()
250
            // Tuple.Create only handles up to 8 params, we have 10+
251
            => Tuple.Create(Tuple.Create(file,
×
252
                                         find,
253
                                         find_regexp,
254
                                         find_matches_files,
255
                                         install_to,
256
                                         @as),
257
                            Tuple.Create(filter,
258
                                         filter_regexp,
259
                                         include_only,
260
                                         include_only_regexp))
261
                    .GetHashCode();
262

263

264
        /// <summary>
265
        /// Returns true if the path provided should be installed by this stanza.
266
        /// </summary>
267
        private bool IsWanted(string path, int? matchWhere)
268
        {
2✔
269
            var pat = InstallPattern;
2✔
270

271
            // Make sure our path always uses slashes we expect.
272
            string normalised_path = path.Replace('\\', '/');
2✔
273

274
            var match = pat.Match(normalised_path);
2✔
275
            if (!match.Success)
2✔
276
            {
2✔
277
                // Doesn't match our install pattern, ignore it
278
                return false;
2✔
279
            }
280
            else if (matchWhere.HasValue && match.Index != matchWhere.Value)
2✔
281
            {
×
282
                // Matches too late in the string, not our folder
283
                return false;
×
284
            }
285

286
            // Get all our path segments. If our filter matches of any them, skip.
287
            // All these comparisons are case insensitive.
288
            var path_segments = new List<string>(normalised_path.ToLower().Split('/'));
2✔
289

290
            if (filter != null && filter.Any(filter_text => path_segments.Contains(filter_text.ToLower())))
2✔
291
            {
2✔
292
                return false;
2✔
293
            }
294

295
            if (filter_regexp != null && filter_regexp.Any(regexp => Regex.IsMatch(normalised_path, regexp)))
2✔
296
            {
2✔
297
                return false;
2✔
298
            }
299

300
            if (include_only != null && include_only.Any(text => path_segments.Contains(text.ToLower())))
2✔
301
            {
2✔
302
                return true;
2✔
303
            }
304

305
            if (include_only_regexp != null && include_only_regexp.Any(regexp => Regex.IsMatch(normalised_path, regexp)))
2✔
306
            {
2✔
307
                return true;
2✔
308
            }
309

310
            return include_only == null && include_only_regexp == null;
2✔
311
        }
2✔
312

313
        /// <summary>
314
        /// Given an open zipfile, returns all files that would be installed
315
        /// for this stanza.
316
        ///
317
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
318
        ///
319
        /// Throws a BadInstallLocationKraken if the install stanza targets an
320
        /// unknown install location (eg: not GameData, Ships, etc)
321
        ///
322
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
323
        /// </summary>
324
        /// <exception cref="BadInstallLocationKraken">Thrown when the installation path is not valid according to the spec.</exception>
325
        public IEnumerable<InstallableFile> FindInstallableFiles(CkanModule module,
326
                                                                 ZipFile    zipfile,
327
                                                                 IGame      game)
328
        {
2✔
329
            string installDir = "";
2✔
330
            int    fileCount  = 0;
2✔
331

332
            // Normalize the path before doing everything else
333
            string install_to = CKANPathUtils.NormalizePath(this.install_to ?? "");
2✔
334

335
            // The installation path cannot contain updirs
336
            if (updirRegex.IsMatch(install_to))
2✔
337
            {
2✔
338
                throw new BadInstallLocationKraken(string.Format(
2✔
339
                    Properties.Resources.ModuleInstallDescriptorInvalidInstallPath, install_to));
340
            }
341

342
            if (install_to == game.PrimaryModDirectoryRelative
2✔
343
                || install_to.StartsWith($"{game.PrimaryModDirectoryRelative}/"))
344
            {
2✔
345
                // The installation path can be either "GameData" or a sub-directory of "GameData"
346
                // remove "GameData"
347
                string subDir = install_to[game.PrimaryModDirectoryRelative.Length..];
2✔
348
                // remove a "/" at the beginning, if present
349
                subDir = subDir.StartsWith("/") ? subDir[1..] : subDir;
2✔
350

351
                // Add the extracted subdirectory to GameData
352
                installDir = CKANPathUtils.NormalizePath(game.PrimaryModDirectoryRelative + "/" + subDir);
2✔
353
            }
2✔
354
            else
355
            {
2✔
356
                switch (install_to)
2✔
357
                {
358
                    case "GameRoot":
NEW
359
                        installDir = "";
×
360
                        break;
×
361

362
                    default:
363
                        if (game.AllowInstallationIn(install_to, out string? path))
2✔
364
                        {
2✔
365
                            installDir = path;
2✔
366
                        }
2✔
367
                        else
368
                        {
2✔
369
                            throw new BadInstallLocationKraken(string.Format(
2✔
370
                                Properties.Resources.ModuleInstallDescriptorUnknownInstallPath, install_to));
371
                        }
372
                        break;
2✔
373
                }
374
            }
2✔
375

376
            var pat = InstallPattern;
2✔
377

378
            // `find` is supposed to match the "topmost" folder. Find it.
379
            var shortestMatch = find == null
2✔
380
                                    ? null
381
                                    : zipfile.Cast<ZipEntry>()
382
                                             .Select(entry => pat.Match(entry.Name.Replace('\\', '/')))
2✔
383
                                             .Where(match => match.Success)
2✔
384
                                             .DefaultIfEmpty()
385
                                             .Min(match => match?.Index);
2✔
386

387
            // O(N^2) solution, as we're walking the zipfile for each stanza.
388
            // Surely there's a better way, although this is fast enough we may not care.
389
            foreach (ZipEntry entry in zipfile)
8✔
390
            {
2✔
391
                // Backslashes are not allowed in filenames according to the ZIP spec,
392
                // but there's a non-conformant PowerShell tool that uses them anyway.
393
                // Try to accommodate those mods.
394
                string entryName = entry.Name.Replace('\\', '/');
2✔
395

396
                // Skips dirs and things not prescribed by our install stanza.
397
                if (!IsWanted(entryName, shortestMatch))
2✔
398
                {
2✔
399
                    continue;
2✔
400
                }
401

402
                // Prepare our file info.
403
                var dest = TransformOutputName(game, entryName, installDir, @as);
2✔
404
                yield return new InstallableFile
2✔
405
                {
406
                    source      = entry,
407
                    makedir     = AllowDirectoryCreation(game, dest),
408
                    destination = dest,
409
                };
410
                ++fileCount;
2✔
411
            }
2✔
412

413
            // If we have no files, then something is wrong! (KSP-CKAN/CKAN#93)
414
            if (fileCount == 0)
2✔
415
            {
2✔
416
                // We have null as the first argument here, because we don't know which module we're installing
417
                throw new BadMetadataKraken(module,
2✔
418
                                            string.Format(Properties.Resources.ModuleInstallDescriptorNoFilesFound,
419
                                                          DescribeMatch()));
420
            }
421
        }
2✔
422

423
        private static readonly Regex updirRegex = new Regex(@"/\.\.(/|$)",
2✔
424
                                                             RegexOptions.Compiled);
425

426
        private static bool AllowDirectoryCreation(IGame game, string relativePath)
427
            => game.CreateableDirs.Any(dir => relativePath == dir
2✔
428
                                           || relativePath.StartsWith($"{dir}/"));
429

430
        /// <summary>
431
        /// Transforms the name of the output. This will strip the leading directories from the stanza file from
432
        /// output name and then combine it with the installDir.
433
        /// EX: "kOS-1.1/GameData/kOS", "kOS-1.1/GameData/kOS/Plugins/kOS.dll", "GameData" will be transformed
434
        /// to "GameData/kOS/Plugins/kOS.dll"
435
        /// </summary>
436
        /// <param name="game">The game to use for reserved paths</param>
437
        /// <param name="outputName">The name of the file to transform</param>
438
        /// <param name="installDir">The installation dir where the file should end up with</param>
439
        /// <param name="as">The name to use for the file</param>
440
        /// <returns>The output name</returns>
441
        internal string TransformOutputName(IGame?  game,
442
                                            string  outputName,
443
                                            string  installDir,
444
                                            string? @as)
445
        {
2✔
446
            var leadingPathToRemove = Path.GetDirectoryName(ShortestMatchingPrefix(outputName))
2✔
447
                                          ?.Replace('\\', '/');
448

449
            if (!string.IsNullOrEmpty(leadingPathToRemove))
2✔
450
            {
2✔
451
                Regex leadingRE = new Regex(
2✔
452
                    "^" + Regex.Escape(leadingPathToRemove) + "/",
453
                    RegexOptions.Compiled);
454
                if (!leadingRE.IsMatch(outputName))
2✔
455
                {
×
456
                    throw new BadMetadataKraken(null, string.Format(
×
457
                        Properties.Resources.ModuleInstallDescriptorNotMatchingLeadingPath,
458
                        outputName, leadingPathToRemove));
459
                }
460
                // Strip off leading path name
461
                outputName = leadingRE.Replace(outputName, "");
2✔
462
            }
2✔
463

464
            // Now outputName looks like PATH/what/ever/file.ext, where
465
            // PATH is the part that matched `file` or `find` or `find_regexp`
466

467
            if (@as != null && !string.IsNullOrWhiteSpace(@as))
2✔
468
            {
2✔
469
                if (@as.Contains("/") || @as.Contains("\\"))
2✔
470
                {
2✔
471
                    throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorAsNoPathSeparators);
2✔
472
                }
473
                // Replace first path component with @as
474
                outputName = ReplaceFirstPiece(outputName, "/", @as);
2✔
475
            }
2✔
476
            else if (game?.ReservedPaths
2✔
477
                          .FirstOrDefault(prefix => outputName.StartsWith(prefix + "/",
2✔
478
                                                                          StringComparison.InvariantCultureIgnoreCase))
479
                     is string reservedPrefix)
480
            {
2✔
481
                // If we try to install a folder with the same name as
482
                // one of the reserved directories, strip it off.
483
                // Delete reservedPrefix and one forward slash
484
                outputName = outputName[(reservedPrefix.Length + 1)..];
2✔
485
            }
2✔
486

487
            if (outputName.Contains("/../") || outputName.EndsWith("/.."))
2✔
488
            {
2✔
489
                throw new BadInstallLocationKraken(
2✔
490
                    string.Format(Properties.Resources.ModuleInstallDescriptorInvalidInstallPath,
491
                                  outputName));
492
            }
493

494
            // Return our snipped, normalised, and ready to go output filename!
495
            return CKANPathUtils.NormalizePath(Path.Combine(installDir, outputName));
2✔
496
        }
2✔
497

498
        private string ShortestMatchingPrefix(string fullPath)
499
        {
2✔
500
            var pat = InstallPattern;
2✔
501

502
            string shortest = fullPath;
2✔
503
            for (var path = trailingSlashPattern.Replace(fullPath.Replace('\\', '/'), "");
2✔
504
                    path != null && !string.IsNullOrEmpty(path);
2✔
505
                    path = Path.GetDirectoryName(path)?.Replace('\\', '/'))
2✔
506
            {
2✔
507
                if (pat.IsMatch(path))
2✔
508
                {
2✔
509
                    shortest = path;
2✔
510
                }
2✔
511
                else
512
                {
2✔
513
                    break;
2✔
514
                }
515
            }
2✔
516
            return shortest;
2✔
517
        }
2✔
518

519
        private static string ReplaceFirstPiece(string text, string delimiter, string replacement)
520
        {
2✔
521
            int pos = text.IndexOf(delimiter);
2✔
522
            if (pos < 0)
2✔
523
            {
2✔
524
                // No delimiter, replace whole string
525
                return replacement;
2✔
526
            }
527
            return replacement + text[pos..];
2✔
528
        }
2✔
529

530
        public string DescribeMatch()
531
            => this switch
2✔
532
               {
533
                   { file:        { Length: > 0 } } => $@"file=""{file}""",
2✔
534
                   { find:        { Length: > 0 } } => $@"find=""{find}""",
2✔
NEW
535
                   { find_regexp: { Length: > 0 } } => $@"find_regexp=""{find_regexp}""",
×
NEW
536
                   _                                => "",
×
537
               };
538
    }
539
}
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