using Dinah.Core; using Polly; using Polly.Retry; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; namespace FileManager; public static class FileUtility { /// /// "txt" => ".txt" ///
".txt" => ".txt" ///
null or whitespace => "" ///
[return: NotNull] public static string GetStandardizedExtension(string? extension) => string.IsNullOrWhiteSpace(extension) ? string.Empty : '.' + 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'); } /// /// Ensure valid file name path: ///
- remove invalid chars ///
- ensure uniqueness ///
- enforce max file length ///
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false) { ArgumentValidator.EnsureNotNull(path, nameof(path)); ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); fileExtension = GetStandardizedExtension(fileExtension); var pathStr = removeInvalidWhitespace(path.Path); var pathWithoutExtension = pathStr.EndsWithInsensitive(fileExtension) ? pathStr[..^fileExtension.Length] : path.Path; // remove invalid chars, but leave file extension untouched pathWithoutExtension = GetSafePath(pathWithoutExtension, replacements); // ensure uniqueness and check lengths var dir = Path.GetDirectoryName(pathWithoutExtension)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty; var filenameWithoutExtension = Path.GetFileName(pathWithoutExtension); var fileStem = Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length)) .TruncateFilename(LongPath.MaxPathLength - fileExtension.Length); var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension; var i = 0; while (File.Exists(fullfilename) && !returnFirstExisting) { var increm = $" ({++i})"; fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension; } return fullfilename; } /// Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/' public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements) { ArgumentValidator.EnsureNotNull(path, nameof(path)); ArgumentValidator.EnsureNotNull(replacements, nameof(replacements)); var pathNoPrefix = path.PathWithoutPrefix; pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix); pathNoPrefix = removeDoubleSlashes(pathNoPrefix); return pathNoPrefix; } 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 separator back off fullfilename = RemoveLastCharacter(fullfilename); fullfilename = removeDoubleSlashes(fullfilename); return fullfilename; } public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1]; public static string TruncateFilename(this string filenameStr, int limit) { if (LongPath.IsWindows) return filenameStr.Truncate(limit); int index = filenameStr.Length; while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit) index--; return filenameStr[..index]; } /// /// Move file. ///
- Ensure valid file name path: remove invalid chars, enforce max file length ///
- Perform ///
/// Name of the file to move /// The new path and name for the file. /// Rules for replacing illegal file path characters /// File extension override to use for /// If false and exists, append " (n)" to filename and try again. /// The actual destination filename public static LongPath SaferMoveToValidPath( LongPath source, LongPath destination, ReplacementCharacters replacements, string? extension = null, bool overwrite = false) { extension ??= Path.GetExtension(source); destination = GetValidFilename(destination, replacements, extension, overwrite); 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(LongPath source) => retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: true)); /// /// Delete file, best-effort. No error when source does not exist. Retries up to 3 times and /// returns false instead of throwing when the file cannot be deleted. /// public static bool TrySaferDelete(LongPath source) { try { retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: false)); return true; } catch (Exception ex) { // Unlike SaferDelete, log only once after retries are exhausted. Best-effort cleanup // callers should not produce an error entry for every retry or need their own wrapper. Serilog.Log.Logger.Warning(ex, "Unable to delete file after retries: {@DebugText}", new { source }); return false; } } private static void deleteFile(LongPath source, bool logAttemptFailure) { 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 ex) { if (logAttemptFailure) Serilog.Log.Logger.Error(ex, "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(LongPath source, LongPath 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); if (dir is null) throw new DirectoryNotFoundException(); 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 /// Called with the reason when the walk ends early, so a caller that must not /// mistake a truncated list for an empty directory can tell the difference. /// List of files public static IEnumerable SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action? onIncomplete = null) { var enumOptions = new EnumerationOptions { RecurseSubdirectories = searchOption == SearchOption.AllDirectories, IgnoreInaccessible = true, ReturnSpecialDirectories = false, MatchType = MatchType.Simple }; return IterateSafely( () => Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p), path, onIncomplete); } /// /// Walks a file system sequence so that a directory which stops being readable partway through ends the walk /// instead of throwing at whoever is consuming it. /// /// only forgives permissions. A disconnected or failing /// volume raises an I/O error from the enumerator itself, and because enumeration is lazy that error is /// raised wherever the sequence is finally walked - which is past any try/catch the caller wrapped around /// the call that produced it. Libation lost a whole session that way: a Books folder on a USB drive that /// started returning I/O errors took down the file cache, the type initializer that builds it, and with it /// every subsequent launch. See issue #1984. /// /// internal static IEnumerable IterateSafely(Func> getSequence, LongPath path, Action? onIncomplete = null) { IEnumerator enumerator; try { //Opening the directory is itself a read, and fails the same way. enumerator = getSequence().GetEnumerator(); } catch (Exception ex) when (IsUnreadable(ex)) { ReportIncomplete(ex, path, onIncomplete); yield break; } try { while (true) { LongPath current; try { if (!enumerator.MoveNext()) break; current = enumerator.Current; } catch (Exception ex) when (IsUnreadable(ex)) { //Whatever has already been read is still good and still worth returning. ReportIncomplete(ex, path, onIncomplete); break; } yield return current; } } finally { enumerator.Dispose(); } } private static bool IsUnreadable(Exception ex) => ex is IOException or UnauthorizedAccessException or System.Security.SecurityException; private static void ReportIncomplete(Exception ex, LongPath path, Action? onIncomplete) { try { //A directory that has simply gone is routine: temp folders are created and cleaned up under a scan //all the time. A directory that is there and cannot be read is worth seeing in a bug report. if (ex is DirectoryNotFoundException) Serilog.Log.Logger.Debug(ex, "Stopped listing files in a directory that is no longer there: {@DebugText}", new { path = (string)path }); else Serilog.Log.Logger.Warning(ex, "Could not finish listing files. The results are incomplete: {@DebugText}", new { path = (string)path }); } catch { /* logging must not be the thing that breaks a file listing */ } onIncomplete?.Invoke(ex); } /// /// Whether a directory can actually be read, as opposed to merely existing. A removable drive that has been /// pulled, and a failing one, can both still answer that they are a directory while every read of them fails. /// public static bool CanEnumerate(LongPath path) { var readable = true; //The first entry is enough. A volume that cannot be read fails on the first attempt, and this must not //pay for walking a whole library to answer the question. foreach (var _ in IterateSafely( () => Directory.EnumerateFileSystemEntries(path.Path).Select(p => (LongPath)p), path, _ => readable = false)) break; return readable; } /// /// Creates a subdirectory or subdirectories on the specified path. /// The specified path can be relative to this instance of the class. /// /// Fixes an issue with where it fails when the parent is a drive root. /// /// The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name. /// The last directory specified in public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path) { if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path)) return parent.CreateSubdirectory(path); // parent is a drive root and subDirectory is relative //Solves a problem with DirectoryInfo.CreateSubdirectory where it fails //If the parent DirectoryInfo is a drive root. var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path)); var directoryInfo = new DirectoryInfo(fullPath); directoryInfo.Create(); return directoryInfo; } }