< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.BlobIdValidator
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/BlobIdValidator.cs
Line coverage
100%
Covered lines: 20
Uncovered lines: 0
Coverable lines: 20
Total lines: 85
Line coverage: 100%
Branch coverage
100%
Covered branches: 12
Total branches: 12
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
IsValid(...)100%66100%
ParseId(...)100%66100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/BlobIdValidator.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace Chronicis.Api.Services.ExternalLinks;
 4
 5/// <summary>
 6/// Validates and parses blob-based external link IDs.
 7/// Security: Prevents path traversal and injection attacks.
 8/// Supports hierarchical IDs like "items/armor/breastplate" for subcategories.
 9/// </summary>
 10public static partial class BlobIdValidator
 11{
 12    // ID must be: alphanumeric + hyphens (case-insensitive), one or more slashes
 13    // Format: "category/slug" OR "category/subcategory/slug"
 14    // Examples: "spells/fireball", "items/armor/breastplate", "bestiary/Beast/aboar"
 15    [GeneratedRegex(@"^[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)+$", RegexOptions.Compiled)]
 16    private static partial Regex ValidIdPattern();
 17
 18    // Characters that are prohibited in IDs (security)
 119    private static readonly char[] ProhibitedChars = { '.', '\\', '[', ']', '|' };
 20
 21    /// <summary>
 22    /// Validates an external link ID against security rules.
 23    /// Supports hierarchical paths with multiple slashes (e.g., "items/armor/breastplate").
 24    /// </summary>
 25    /// <param name="id">ID to validate.</param>
 26    /// <param name="error">Error message if validation fails.</param>
 27    /// <returns>True if valid, false otherwise.</returns>
 28    public static bool IsValid(string id, out string? error)
 29    {
 1730        error = null;
 31
 1732        if (string.IsNullOrWhiteSpace(id))
 33        {
 334            error = "ID cannot be empty";
 335            return false;
 36        }
 37
 38        // Check for prohibited characters (path traversal, injection attempts)
 1439        if (id.IndexOfAny(ProhibitedChars) >= 0)
 40        {
 741            error = "ID contains prohibited characters (. \\ [ ] |)";
 742            return false;
 43        }
 44
 45        // Validate format: lowercase alphanumeric + hyphens with at least one slash
 746        if (!ValidIdPattern().IsMatch(id))
 47        {
 448            error = "ID must have format 'category/slug' or 'category/subcategory/slug' with alphanumeric and hyphens on
 449            return false;
 50        }
 51
 352        return true;
 53    }
 54
 55    /// <summary>
 56    /// Parses a validated ID into category path and slug components.
 57    /// For hierarchical IDs, category includes all path segments except the last.
 58    /// </summary>
 59    /// <param name="id">ID to parse (e.g., "spells/fireball" or "items/armor/breastplate").</param>
 60    /// <returns>
 61    /// Tuple of (categoryPath, slug):
 62    /// - "spells/fireball" -> ("spells", "fireball")
 63    /// - "items/armor/breastplate" -> ("items/armor", "breastplate")
 64    /// Returns (null, null) if invalid format.
 65    /// </returns>
 66    public static (string? categoryPath, string? slug) ParseId(string id)
 67    {
 768        if (string.IsNullOrWhiteSpace(id))
 69        {
 270            return (null, null);
 71        }
 72
 573        var lastSlashIndex = id.LastIndexOf('/');
 574        if (lastSlashIndex <= 0 || lastSlashIndex >= id.Length - 1)
 75        {
 76            // No slash, or slash at start/end
 377            return (null, null);
 78        }
 79
 280        var categoryPath = id[..lastSlashIndex];
 281        var slug = id[(lastSlashIndex + 1)..];
 82
 283        return (categoryPath, slug);
 84    }
 85}