using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using Dinah.Core; using Polly; using Polly.Retry; namespace FileManager { public static class FileUtility { /// /// "txt" => ".txt" ///
".txt" => ".txt" ///
null or whitespace => "" ///
public static string GetStandardizedExtension(string extension) => string.IsNullOrWhiteSpace(extension) ? (extension ?? "")?.Trim() : '.' + extension.Trim().Trim('.'); /// /// Return position with correct number of leading zeros. ///
- 2 of 9 => "2" ///
- 2 of 90 => "02" ///
- 2 of 900 => "002" ///
/// position in sequence. The 'x' in 'x of y' /// total qty in sequence. The 'y' in 'x of y' public static string GetSequenceFormatted(int position, int total) { ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0); ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0); if (position > total) throw new ArgumentException($"{position} may not be greater than {total}"); return position.ToString().PadLeft(total.ToString().Length, '0'); } private const int MAX_FILENAME_LENGTH = 255; private const int MAX_DIRECTORY_LENGTH = 247; /// /// Ensure valid file name path: ///
- remove invalid chars ///
- ensure uniqueness ///
- enforce max file length ///
public static string GetValidFilename(string path, string illegalCharacterReplacements = "", bool returnFirstExisting = false) { ArgumentValidator.EnsureNotNull(path, nameof(path)); // remove invalid chars path = GetSafePath(path, illegalCharacterReplacements); // ensure uniqueness and check lengths var dir = Path.GetDirectoryName(path); dir = dir.Truncate(MAX_DIRECTORY_LENGTH); var filename = Path.GetFileNameWithoutExtension(path); var fileStem = Path.Combine(dir, filename); var extension = Path.GetExtension(path); var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension; fullfilename = removeInvalidWhitespace(fullfilename); var i = 0; while (File.Exists(fullfilename) && !returnFirstExisting) { var increm = $" ({++i})"; fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension; } return fullfilename; } // GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/' /// Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/' public static string GetSafeFileName(string str, string illegalCharacterReplacements = "") => string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars())); /// Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/' public static string GetSafePath(string path, string illegalCharacterReplacements = "") { ArgumentValidator.EnsureNotNull(path, nameof(path)); path = replaceInvalidChars(path, illegalCharacterReplacements); path = standardizeSlashes(path); path = replaceColons(path, illegalCharacterReplacements); path = removeDoubleSlashes(path); return path; } private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] { '*', '?', // these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included. // In live code, Path.GetInvalidPathChars() does not include them '"', '<', '>' }).ToArray(); private static string replaceInvalidChars(string path, string illegalCharacterReplacements) => string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars)); private static string standardizeSlashes(string path) => path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); private static string replaceColons(string path, string illegalCharacterReplacements) { // replace all colons except within the first 2 chars var builder = new System.Text.StringBuilder(); for (var i = 0; i < path.Length; i++) { var c = path[i]; if (i >= 2 && c == ':') builder.Append(illegalCharacterReplacements); else builder.Append(c); } return builder.ToString(); } private static string removeDoubleSlashes(string path) { if (path.Length < 2) return path; // exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1 var remainder = path[1..]; var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}"; while (remainder.Contains(dblSeparator)) remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}"); return path[0] + remainder; } private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*"; private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); /// no part of the path may begin or end in whitespace private static string removeInvalidWhitespace(string fullfilename) { // no whitespace at beginning or end // replace whitespace around path slashes // regex (with space added for clarity) // \s* \\ \s* => \ // no ending dots. beginning dots are valid // regex is easier by ending with separator fullfilename += Path.DirectorySeparatorChar; fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString()); // take seperator back off fullfilename = RemoveLastCharacter(fullfilename); fullfilename = removeDoubleSlashes(fullfilename); return fullfilename; } public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1]; /// /// Move file. ///
- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length ///
- Perform ///
- Return valid path ///
public static string SaferMoveToValidPath(string source, string destination) { destination = GetValidFilename(destination); SaferMove(source, destination); return destination; } private static int maxRetryAttempts { get; } = 3; private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100); private static RetryPolicy retryPolicy { get; } = Policy .Handle() .WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures); /// Delete file. No error when source does not exist. Retry up to 3 times before throwing exception. public static void SaferDelete(string source) => retryPolicy.Execute(() => { try { if (!File.Exists(source)) { Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source }); return; } Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source }); File.Delete(source); Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source }); } catch (Exception e) { Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source }); throw; } }); /// Move file. No error when source does not exist. Retry up to 3 times before throwing exception. public static void SaferMove(string source, string destination) => retryPolicy.Execute(() => { try { if (!File.Exists(source)) { Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source }); return; } SaferDelete(destination); var dir = Path.GetDirectoryName(destination); Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir }); Directory.CreateDirectory(dir); Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination }); File.Move(source, destination); Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination }); } catch (Exception e) { Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination }); throw; } }); /// /// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException /// /// Starting directory /// Filename pattern match /// Search subdirectories or only top level directory for files /// List of files public static IEnumerable SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) { var foundFiles = Enumerable.Empty(); if (searchOption == SearchOption.AllDirectories) { try { IEnumerable subDirs = Directory.EnumerateDirectories(path); // Add files in subdirectories recursively to the list foreach (string dir in subDirs) foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption)); } catch (UnauthorizedAccessException) { } catch (PathTooLongException) { } } try { // Add files from the current directory foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern)); } catch (UnauthorizedAccessException) { } return foundFiles; } } }