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

KSP-CKAN / CKAN / 17904669167

22 Sep 2025 04:34AM UTC coverage: 75.604% (+1.2%) from 74.397%
17904669167

push

github

HebaruSan
Merge #4443 Report number of filtered files in install

5231 of 7236 branches covered (72.29%)

Branch coverage included in aggregate %.

192 of 218 new or added lines in 41 files covered. (88.07%)

35 existing lines in 7 files now uncovered.

11163 of 14448 relevant lines covered (77.26%)

1.58 hits per line

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

80.43
/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;
8
using System.Text.RegularExpressions;
9
using System.Runtime.CompilerServices;
10
using System.Reflection;
11

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

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

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

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

26
        #region Properties
27

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

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

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

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

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

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

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

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

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

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

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

68
        private static readonly Regex trailingSlashPattern = new Regex("/$",
2✔
69
            RegexOptions.Compiled);
70

71
        [OnDeserialized]
72
        internal void DeSerialisationFixes(StreamingContext like_i_could_care)
73
        {
2✔
74
            // Make sure our install_to fields exists. We may be able to remove
75
            // this check now that we're doing better json-fu above.
76
            if (install_to == null)
2!
77
            {
×
78
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorMustHaveInstallTo);
×
79
            }
80

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

83
            // Make sure we have either a `file`, `find`, or `find_regexp` stanza.
84
            if (setCount == 0)
2✔
85
            {
2✔
86
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorRequireFileFind);
2✔
87
            }
88

89
            if (setCount > 1)
2!
90
            {
×
91
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorTooManyFileFind);
×
92
            }
93

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

98
            if (filterCount > 0 && includeOnlyCount > 0)
2!
99
            {
×
100
                throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorTooManyFilterInclude);
×
101
            }
102

103
            // Normalize paths on load (note, doesn't cover assignment like in tests)
104
            install_to = CKANPathUtils.NormalizePath(install_to);
2✔
105
        }
2✔
106

107
        #endregion
108

109
        #region Constructors and clones
110

111
        [JsonConstructor]
112
        private ModuleInstallDescriptor()
2✔
113
        {
2✔
114
            install_to = typeof(ModuleInstallDescriptor).GetTypeInfo()
2✔
115
                                                        ?.GetDeclaredField("install_to")
116
                                                        ?.GetCustomAttribute<DefaultValueAttribute>()
117
                                                        ?.Value
118
                                                        ?.ToString();
119
        }
2✔
120

121
        /// <summary>
122
        /// Returns a deep clone of our object. Implements ICloneable.
123
        /// </summary>
124
        public object Clone()
125
            // Deep clone our object by running it through a serialisation cycle.
126
            => JsonConvert.DeserializeObject<ModuleInstallDescriptor>(JsonConvert.SerializeObject(this, Formatting.None))!;
×
127

128
        /// <summary>
129
        /// Compare two install stanzas
130
        /// </summary>
131
        /// <param name="other">The other stanza for comparison</param>
132
        /// <returns>
133
        /// True if they're equivalent, false if they're different.
134
        /// </returns>
135
        public override bool Equals(object? other)
136
        {
2✔
137
            return Equals(other as ModuleInstallDescriptor);
2✔
138
        }
2✔
139

140
        /// <summary>
141
        /// Compare two install stanzas
142
        /// </summary>
143
        /// <param name="otherStanza">The other stanza for comparison</param>
144
        /// <returns>
145
        /// True if they're equivalent, false if they're different.
146
        /// IEquatable&lt;&gt; uses this for more efficient comparisons.
147
        /// </returns>
148
        public bool Equals(ModuleInstallDescriptor? otherStanza)
149
        {
2✔
150
            if (otherStanza == null)
2✔
151
            {
2✔
152
                // Not even the right type!
153
                return false;
2✔
154
            }
155

156
            if (CKANPathUtils.NormalizePath(file ?? "") != CKANPathUtils.NormalizePath(otherStanza.file ?? ""))
2!
157
            {
×
158
                return false;
×
159
            }
160

161
            if (CKANPathUtils.NormalizePath(find ?? "") != CKANPathUtils.NormalizePath(otherStanza.find ?? ""))
2!
162
            {
×
163
                return false;
×
164
            }
165

166
            if (find_regexp != otherStanza.find_regexp)
2!
167
            {
×
168
                return false;
×
169
            }
170

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

176
            if (@as != otherStanza.@as)
2!
177
            {
×
178
                return false;
×
179
            }
180

181
            if ((filter == null) != (otherStanza.filter == null))
2!
182
            {
×
183
                return false;
×
184
            }
185

186
            if (filter != null && otherStanza.filter != null
2!
187
                && !filter.SequenceEqual(otherStanza.filter))
188
            {
×
189
                return false;
×
190
            }
191

192
            if ((filter_regexp == null) != (otherStanza.filter_regexp == null))
2!
193
            {
×
194
                return false;
×
195
            }
196

197
            if (filter_regexp != null && otherStanza.filter_regexp != null
2!
198
                && !filter_regexp.SequenceEqual(otherStanza.filter_regexp))
199
            {
×
200
                return false;
×
201
            }
202

203
            if (find_matches_files != otherStanza.find_matches_files)
2!
204
            {
×
205
                return false;
×
206
            }
207

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

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

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

224
            if (include_only_regexp != null && otherStanza.include_only_regexp != null
2!
225
                && !include_only_regexp.SequenceEqual(otherStanza.include_only_regexp))
226
            {
×
227
                return false;
×
228
            }
229

230
            return true;
2✔
231
        }
2✔
232

233
        public override int GetHashCode()
234
        {
×
235
            // Tuple.Create only handles up to 8 params, we have 10+
236
            return Tuple.Create(
×
237
                Tuple.Create(
238
                    file,
239
                    find,
240
                    find_regexp,
241
                    find_matches_files,
242
                    install_to,
243
                    @as
244
                ),
245
                Tuple.Create(
246
                    filter,
247
                    filter_regexp,
248
                    include_only,
249
                    include_only_regexp
250
                )
251
            ).GetHashCode();
252
        }
×
253

254
        /// <summary>
255
        /// Returns a default install stanza for the identifier provided.
256
        /// </summary>
257
        /// <returns>
258
        /// { "find": "ident", "install_to": "GameData" }
259
        /// </returns>
260
        public static ModuleInstallDescriptor DefaultInstallStanza(IGame game, string ident)
261
        {
2✔
262
            return new ModuleInstallDescriptor()
2✔
263
            {
264
                find       = ident,
265
                install_to = game.PrimaryModDirectoryRelative,
266
            };
267
        }
2✔
268

269
        #endregion
270

271
        private Regex EnsurePattern()
272
        {
2✔
273
            if (inst_pattern == null)
2✔
274
            {
2✔
275
                if (file != null)
2✔
276
                {
2✔
277
                    file = CKANPathUtils.NormalizePath(file);
2✔
278
                    inst_pattern = new Regex(@"^" + Regex.Escape(file) + @"(/|$)",
2✔
279
                        RegexOptions.IgnoreCase | RegexOptions.Compiled);
280
                }
2✔
281
                else if (find != null)
2!
282
                {
2✔
283
                    find = CKANPathUtils.NormalizePath(find);
2✔
284
                    inst_pattern = new Regex(@"(?:^|/)" + Regex.Escape(find) + @"(/|$)",
2✔
285
                        RegexOptions.IgnoreCase | RegexOptions.Compiled);
286
                }
2✔
287
                else if (find_regexp != null)
×
288
                {
×
289
                    inst_pattern = new Regex(find_regexp,
×
290
                        RegexOptions.IgnoreCase | RegexOptions.Compiled);
291
                }
×
292
                else
293
                {
×
294
                    throw new Kraken(Properties.Resources.ModuleInstallDescriptorRequireFileFind);
×
295
                }
296
            }
2✔
297
            return inst_pattern;
2✔
298
        }
2✔
299

300
        /// <summary>
301
        /// Returns true if the path provided should be installed by this stanza.
302
        /// Can *only* be used on `file` stanzas, throws an UnsupportedKraken if called
303
        /// on a `find` stanza.
304
        /// Use `ConvertFindToFile` to convert `find` to `file` stanzas.
305
        /// </summary>
306
        private bool IsWanted(string path, int? matchWhere)
307
        {
2✔
308
            var pat = EnsurePattern();
2✔
309

310
            // Make sure our path always uses slashes we expect.
311
            string normalised_path = path.Replace('\\', '/');
2✔
312

313
            var match = pat.Match(normalised_path);
2✔
314
            if (!match.Success)
2✔
315
            {
2✔
316
                // Doesn't match our install pattern, ignore it
317
                return false;
2✔
318
            }
319
            else if (matchWhere.HasValue && match.Index != matchWhere.Value)
2!
320
            {
×
321
                // Matches too late in the string, not our folder
322
                return false;
×
323
            }
324

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

329
            if (filter != null && filter.Any(filter_text => path_segments.Contains(filter_text.ToLower())))
2✔
330
            {
2✔
331
                return false;
2✔
332
            }
333

334
            if (filter_regexp != null && filter_regexp.Any(regexp => Regex.IsMatch(normalised_path, regexp)))
2✔
335
            {
2✔
336
                return false;
2✔
337
            }
338

339
            if (include_only != null && include_only.Any(text => path_segments.Contains(text.ToLower())))
2✔
340
            {
2✔
341
                return true;
2✔
342
            }
343

344
            if (include_only_regexp != null && include_only_regexp.Any(regexp => Regex.IsMatch(normalised_path, regexp)))
2✔
345
            {
2✔
346
                return true;
2✔
347
            }
348

349
            return include_only == null && include_only_regexp == null;
2✔
350
        }
2✔
351

352
        /// <summary>
353
        /// Given an open zipfile, returns all files that would be installed
354
        /// for this stanza.
355
        ///
356
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
357
        ///
358
        /// Throws a BadInstallLocationKraken if the install stanza targets an
359
        /// unknown install location (eg: not GameData, Ships, etc)
360
        ///
361
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
362
        /// </summary>
363
        /// <exception cref="BadInstallLocationKraken">Thrown when the installation path is not valid according to the spec.</exception>
364
        public List<InstallableFile> FindInstallableFiles(ZipFile zipfile, GameInstance? ksp)
365
        {
2✔
366
            string? installDir;
367
            var files = new List<InstallableFile>();
2✔
368

369
            // Normalize the path before doing everything else
370
            string? install_to = CKANPathUtils.NormalizePath(this.install_to ?? "");
2✔
371

372
            // The installation path cannot contain updirs
373
            if (install_to.Contains("/../") || install_to.EndsWith("/.."))
2✔
374
            {
2✔
375
                throw new BadInstallLocationKraken(string.Format(
2✔
376
                    Properties.Resources.ModuleInstallDescriptorInvalidInstallPath, install_to));
377
            }
378

379
            if (ksp == null)
2✔
380
            {
2✔
381
                installDir = install_to;
2✔
382
            }
2✔
383
            else if (install_to == ksp.Game.PrimaryModDirectoryRelative
2✔
384
                || install_to.StartsWith($"{ksp.Game.PrimaryModDirectoryRelative}/"))
385
            {
2✔
386
                // The installation path can be either "GameData" or a sub-directory of "GameData"
387
                string subDir = install_to[ksp.Game.PrimaryModDirectoryRelative.Length..];    // remove "GameData"
2✔
388
                subDir = subDir.StartsWith("/") ? subDir[1..] : subDir;    // remove a "/" at the beginning, if present
2✔
389

390
                // Add the extracted subdirectory to the path of KSP's GameData
391
                installDir = CKANPathUtils.NormalizePath(ksp.Game.PrimaryModDirectory(ksp) + "/" + subDir);
2✔
392
            }
2✔
393
            else
394
            {
2✔
395
                switch (install_to)
2!
396
                {
397
                    case "GameRoot":
NEW
398
                        installDir = ksp.GameDir;
×
399
                        break;
×
400

401
                    default:
402
                        if (ksp.Game.AllowInstallationIn(install_to, out string? path))
2✔
403
                        {
2✔
404
                            installDir = ksp.ToAbsoluteGameDir(path);
2✔
405
                        }
2✔
406
                        else
407
                        {
2✔
408
                            throw new BadInstallLocationKraken(string.Format(
2✔
409
                                Properties.Resources.ModuleInstallDescriptorUnknownInstallPath, install_to));
410
                        }
411
                        break;
2✔
412
                }
413
            }
2✔
414

415
            var pat = EnsurePattern();
2✔
416

417
            // `find` is supposed to match the "topmost" folder. Find it.
418
            var shortestMatch = find == null ? null
2✔
419
                : zipfile.Cast<ZipEntry>()
420
                    .Select(entry => pat.Match(entry.Name.Replace('\\', '/')))
2✔
421
                    .Where(match => match.Success)
2✔
422
                    .DefaultIfEmpty()
423
                    .Min(match => match?.Index);
2!
424

425
            // O(N^2) solution, as we're walking the zipfile for each stanza.
426
            // Surely there's a better way, although this is fast enough we may not care.
427
            foreach (ZipEntry entry in zipfile)
5✔
428
            {
2✔
429
                // Backslashes are not allowed in filenames according to the ZIP spec,
430
                // but there's a non-conformant PowerShell tool that uses them anyway.
431
                // Try to accommodate those mods.
432
                string entryName = entry.Name.Replace('\\', '/');
2✔
433

434
                // Skips dirs and things not prescribed by our install stanza.
435
                if (!IsWanted(entryName, shortestMatch))
2✔
436
                {
2✔
437
                    continue;
2✔
438
                }
439

440
                // Prepare our file info.
441
                var file_info = new InstallableFile
2✔
442
                {
443
                    source      = entry,
444
                    makedir     = false,
445
                    destination = "",
446
                };
447

448
                // If we have a place to install it, fill that in...
449
                if (installDir != null)
2!
450
                {
2✔
451
                    // Get the full name of the file.
452
                    // Update our file info with the install location
453
                    file_info.destination = TransformOutputName(ksp?.Game, entryName, installDir, @as);
2✔
454
                    if (ksp != null)
2✔
455
                    {
2✔
456
                        file_info.makedir = AllowDirectoryCreation(ksp.Game,
2✔
457
                                                                   ksp.ToRelativeGameDir(file_info.destination));
458
                    }
2✔
459
                }
2✔
460

461
                files.Add(file_info);
2✔
462
            }
2✔
463

464
            // If we have no files, then something is wrong! (KSP-CKAN/CKAN#93)
465
            if (files.Count == 0)
2✔
466
            {
2✔
467
                // We have null as the first argument here, because we don't know which module we're installing
468
                throw new BadMetadataKraken(null, string.Format(
2✔
469
                    Properties.Resources.ModuleInstallDescriptorNoFilesFound, DescribeMatch()));
470
            }
471

472
            return files;
2✔
473
        }
2✔
474

475
        private static bool AllowDirectoryCreation(IGame game, string relativePath)
476
            => game.CreateableDirs.Any(dir => relativePath == dir || relativePath.StartsWith($"{dir}/"));
2!
477

478
        /// <summary>
479
        /// Transforms the name of the output. This will strip the leading directories from the stanza file from
480
        /// output name and then combine it with the installDir.
481
        /// EX: "kOS-1.1/GameData/kOS", "kOS-1.1/GameData/kOS/Plugins/kOS.dll", "GameData" will be transformed
482
        /// to "GameData/kOS/Plugins/kOS.dll"
483
        /// </summary>
484
        /// <param name="game">The game to use for reserved paths</param>
485
        /// <param name="outputName">The name of the file to transform</param>
486
        /// <param name="installDir">The installation dir where the file should end up with</param>
487
        /// <param name="as">The name to use for the file</param>
488
        /// <returns>The output name</returns>
489
        internal string TransformOutputName(IGame?  game,
490
                                            string  outputName,
491
                                            string  installDir,
492
                                            string? @as)
493
        {
2✔
494
            var leadingPathToRemove = Path.GetDirectoryName(ShortestMatchingPrefix(outputName))
2✔
495
                                          ?.Replace('\\', '/');
496

497
            if (!string.IsNullOrEmpty(leadingPathToRemove))
2✔
498
            {
2✔
499
                Regex leadingRE = new Regex(
2✔
500
                    "^" + Regex.Escape(leadingPathToRemove) + "/",
501
                    RegexOptions.Compiled);
502
                if (!leadingRE.IsMatch(outputName))
2!
503
                {
×
504
                    throw new BadMetadataKraken(null, string.Format(
×
505
                        Properties.Resources.ModuleInstallDescriptorNotMatchingLeadingPath,
506
                        outputName, leadingPathToRemove));
507
                }
508
                // Strip off leading path name
509
                outputName = leadingRE.Replace(outputName, "");
2✔
510
            }
2✔
511

512
            // Now outputName looks like PATH/what/ever/file.ext, where
513
            // PATH is the part that matched `file` or `find` or `find_regexp`
514

515
            if (@as != null && !string.IsNullOrWhiteSpace(@as))
2✔
516
            {
2✔
517
                if (@as.Contains("/") || @as.Contains("\\"))
2✔
518
                {
2✔
519
                    throw new BadMetadataKraken(null, Properties.Resources.ModuleInstallDescriptorAsNoPathSeparators);
2✔
520
                }
521
                // Replace first path component with @as
522
                outputName = ReplaceFirstPiece(outputName, "/", @as);
2✔
523
            }
2✔
524
            else if (game?.ReservedPaths
2✔
525
                          .FirstOrDefault(prefix => outputName.StartsWith(prefix + "/",
2✔
526
                                                                          StringComparison.InvariantCultureIgnoreCase))
527
                     is string reservedPrefix)
528
            {
2✔
529
                // If we try to install a folder with the same name as
530
                // one of the reserved directories, strip it off.
531
                // Delete reservedPrefix and one forward slash
532
                outputName = outputName[(reservedPrefix.Length + 1)..];
2✔
533
            }
2✔
534

535
            if (outputName.Contains("/../") || outputName.EndsWith("/.."))
2✔
536
            {
2✔
537
                throw new BadInstallLocationKraken(
2✔
538
                    string.Format(Properties.Resources.ModuleInstallDescriptorInvalidInstallPath,
539
                                  outputName));
540
            }
541

542
            // Return our snipped, normalised, and ready to go output filename!
543
            return CKANPathUtils.NormalizePath(Path.Combine(installDir, outputName));
2✔
544
        }
2✔
545

546
        private string ShortestMatchingPrefix(string fullPath)
547
        {
2✔
548
            var pat = EnsurePattern();
2✔
549

550
            string shortest = fullPath;
2✔
551
            for (var path = trailingSlashPattern.Replace(fullPath.Replace('\\', '/'), "");
2✔
552
                    path != null && !string.IsNullOrEmpty(path);
2✔
553
                    path = Path.GetDirectoryName(path)?.Replace('\\', '/'))
2✔
554
            {
2✔
555
                if (pat.IsMatch(path))
2✔
556
                {
2✔
557
                    shortest = path;
2✔
558
                }
2✔
559
                else
560
                {
2✔
561
                    break;
2✔
562
                }
563
            }
2✔
564
            return shortest;
2✔
565
        }
2✔
566

567
        private static string ReplaceFirstPiece(string text, string delimiter, string replacement)
568
        {
2✔
569
            int pos = text.IndexOf(delimiter);
2✔
570
            if (pos < 0)
2✔
571
            {
2✔
572
                // No delimiter, replace whole string
573
                return replacement;
2✔
574
            }
575
            return replacement + text[pos..];
2✔
576
        }
2✔
577

578
        public string DescribeMatch()
579
        {
2✔
580
            StringBuilder sb = new StringBuilder();
2✔
581
            if (!string.IsNullOrEmpty(file))
2✔
582
            {
2✔
583
                sb.AppendFormat("file=\"{0}\"", file);
2✔
584
            }
2✔
585
            if (!string.IsNullOrEmpty(find))
2✔
586
            {
2✔
587
                sb.AppendFormat("find=\"{0}\"", find);
2✔
588
            }
2✔
589
            if (!string.IsNullOrEmpty(find_regexp))
2!
590
            {
×
591
                sb.AppendFormat("find_regexp=\"{0}\"", find_regexp);
×
592
            }
×
593
            return sb.ToString();
2✔
594
        }
2✔
595
    }
596
}
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