|
| 1 | +using System; |
| 2 | +using System.IO; |
| 3 | +using System.Linq; |
| 4 | + |
| 5 | +namespace EngineLayer.Util |
| 6 | +{ |
| 7 | + public static class PathSafety |
| 8 | + { |
| 9 | + // Conservative defaults for broad Windows tooling compatibility. |
| 10 | + // Increase if your environment supports long paths (\\?\). |
| 11 | + public const int DefaultMaxPath = 260; |
| 12 | + public const int DefaultMaxFileName = 255; |
| 13 | + |
| 14 | + /// <summary> |
| 15 | + /// Validates and sanitizes a path for an output file. Ensures the result: |
| 16 | + /// - Ends with <paramref name="requiredEnding"/> (case-insensitive; appended if missing). The ending may be a complex suffix, |
| 17 | + /// e.g., "-{identifier}.ext", not just a simple extension. |
| 18 | + /// - Replaces invalid filename characters in the base portion with '_', preserving the required ending verbatim. |
| 19 | + /// - Avoids Windows reserved device names by prefixing an underscore (applied to the name-without-extension). |
| 20 | + /// - Trims only the base portion (before the required ending) to respect filename and total path limits. |
| 21 | + /// Throws if the directory portion alone exceeds <paramref name="maxPath"/> or if a valid filename cannot be constructed. |
| 22 | + /// </summary> |
| 23 | + /// <param name="pathToValidate">Proposed output path (relative or absolute).</param> |
| 24 | + /// <param name="requiredEnding"> |
| 25 | + /// Required trailing suffix to enforce (verbatim). Examples: ".mzML", "-run42.mzML", "_v1.json". |
| 26 | + /// Matching is case-insensitive, but the supplied suffix text is preserved on output. |
| 27 | + /// </param> |
| 28 | + /// <param name="maxPath">Maximum total path length (default 260).</param> |
| 29 | + /// <param name="maxFileName">Maximum filename length (default 255).</param> |
| 30 | + /// <returns>Sanitized, safe path.</returns> |
| 31 | + /// <exception cref="ArgumentException">Null/empty inputs.</exception> |
| 32 | + /// <exception cref="PathTooLongException">Directory alone exceeds limit, or no valid filename can be constructed within limits.</exception> |
| 33 | + public static string MakeSafeOutputPath( |
| 34 | + string pathToValidate, |
| 35 | + string requiredEnding, |
| 36 | + int maxPath = DefaultMaxPath, |
| 37 | + int maxFileName = DefaultMaxFileName) |
| 38 | + { |
| 39 | + if (string.IsNullOrWhiteSpace(pathToValidate)) |
| 40 | + throw new ArgumentException("Path is null or whitespace.", nameof(pathToValidate)); |
| 41 | + if (string.IsNullOrWhiteSpace(requiredEnding)) |
| 42 | + throw new ArgumentException("Required ending is null or whitespace.", nameof(requiredEnding)); |
| 43 | + |
| 44 | + // Split directory and filename |
| 45 | + string directory = Path.GetDirectoryName(pathToValidate) ?? string.Empty; |
| 46 | + string fileName = Path.GetFileName(pathToValidate); |
| 47 | + |
| 48 | + // If no filename provided, synthesize a base |
| 49 | + if (string.IsNullOrWhiteSpace(fileName)) |
| 50 | + fileName = "output"; |
| 51 | + |
| 52 | + // Ensure the filename ends with the requiredEnding (case-insensitive), without duplicating. |
| 53 | + // We will preserve the caller's requiredEnding verbatim. |
| 54 | + bool hasEnding = fileName.EndsWith(requiredEnding, StringComparison.OrdinalIgnoreCase); |
| 55 | + string basePart; |
| 56 | + if (hasEnding) |
| 57 | + { |
| 58 | + // Split into base + ending using the requiredEnding length from the end. |
| 59 | + basePart = fileName.Substring(0, fileName.Length - requiredEnding.Length); |
| 60 | + } |
| 61 | + else |
| 62 | + { |
| 63 | + basePart = fileName; |
| 64 | + } |
| 65 | + |
| 66 | + // Sanitize only the base portion (so we keep the requiredEnding exactly as provided). |
| 67 | + var invalid = Path.GetInvalidFileNameChars(); |
| 68 | + basePart = new string(basePart.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); |
| 69 | + |
| 70 | + // Reassemble filename, appending ending only if it wasn't already present. |
| 71 | + string endingPart = requiredEnding; |
| 72 | + string combinedFileName = basePart + endingPart; |
| 73 | + |
| 74 | + // Avoid reserved device names (Windows) by checking the name without extension |
| 75 | + combinedFileName = AvoidReservedDeviceNames(combinedFileName); |
| 76 | + |
| 77 | + // Enforce filename length by trimming base (not the required ending) |
| 78 | + if (combinedFileName.Length > maxFileName) |
| 79 | + { |
| 80 | + int maxBaseLen = Math.Max(1, maxFileName - endingPart.Length); |
| 81 | + // Recompute basePart against the current combinedFileName to be safe: |
| 82 | + string currentBase = combinedFileName.Substring(0, combinedFileName.Length - endingPart.Length); |
| 83 | + if (currentBase.Length > maxBaseLen) |
| 84 | + currentBase = currentBase.Substring(0, maxBaseLen); |
| 85 | + |
| 86 | + combinedFileName = currentBase + endingPart; |
| 87 | + } |
| 88 | + |
| 89 | + // Combine with directory |
| 90 | + string result = CombineDirectoryAndFile(directory, combinedFileName); |
| 91 | + |
| 92 | + // Enforce overall path limit by trimming base further if necessary |
| 93 | + if (result.Length > maxPath) |
| 94 | + { |
| 95 | + string dirWithSep = EnsureDirWithSeparator(directory); |
| 96 | + int maxFileLenGivenDir = maxPath - dirWithSep.Length; |
| 97 | + if (maxFileLenGivenDir <= 0) |
| 98 | + throw new PathTooLongException("Directory portion exceeds maximum path length limit."); |
| 99 | + |
| 100 | + int allowedBase = Math.Max(1, maxFileLenGivenDir - endingPart.Length); |
| 101 | + if (allowedBase <= 0) |
| 102 | + throw new PathTooLongException("Cannot construct a valid filename within the path length limit."); |
| 103 | + |
| 104 | + // Trim base accordingly |
| 105 | + string trimmedBase = combinedFileName.Substring(0, Math.Max(1, allowedBase)); |
| 106 | + if (trimmedBase.Length > allowedBase) |
| 107 | + trimmedBase = trimmedBase.Substring(0, allowedBase); |
| 108 | + |
| 109 | + combinedFileName = trimmedBase + endingPart; |
| 110 | + result = dirWithSep + combinedFileName; |
| 111 | + |
| 112 | + if (result.Length > maxPath) |
| 113 | + throw new PathTooLongException("Resulting path exceeds maximum path length after trimming."); |
| 114 | + } |
| 115 | + |
| 116 | + return result; |
| 117 | + } |
| 118 | + |
| 119 | + private static string EnsureDirWithSeparator(string directory) |
| 120 | + { |
| 121 | + if (string.IsNullOrEmpty(directory)) |
| 122 | + return string.Empty; |
| 123 | + char sep = Path.DirectorySeparatorChar; |
| 124 | + return directory.EndsWith(sep.ToString(), StringComparison.Ordinal) ? directory : directory + sep; |
| 125 | + } |
| 126 | + |
| 127 | + private static string CombineDirectoryAndFile(string directory, string fileName) |
| 128 | + { |
| 129 | + if (string.IsNullOrEmpty(directory)) |
| 130 | + return fileName; |
| 131 | + return Path.Combine(directory, fileName); |
| 132 | + } |
| 133 | + |
| 134 | + private static string AvoidReservedDeviceNames(string fileName) |
| 135 | + { |
| 136 | + string nameNoExt = Path.GetFileNameWithoutExtension(fileName); |
| 137 | + string ext = Path.GetExtension(fileName); |
| 138 | + |
| 139 | + var reserved = new[] |
| 140 | + { |
| 141 | + "con", "prn", "aux", "nul", |
| 142 | + "com1","com2","com3","com4","com5","com6","com7","com8","com9", |
| 143 | + "lpt1","lpt2","lpt3","lpt4","lpt5","lpt6","lpt7","lpt8","lpt9" |
| 144 | + }; |
| 145 | + |
| 146 | + if (reserved.Contains(nameNoExt.ToLowerInvariant())) |
| 147 | + nameNoExt = "_" + nameNoExt; |
| 148 | + |
| 149 | + return nameNoExt + ext; |
| 150 | + } |
| 151 | + } |
| 152 | +} |
0 commit comments