mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 13:47:16 -04:00
SaferEnumerateFiles returned a lazy sequence, so an I/O error was raised where the sequence was walked rather than where it was created - past the try/catch callers had wrapped around it. IgnoreInaccessible did not help either: it only forgives permissions, not a volume that has stopped answering. Walk the enumerator defensively instead, keeping what was read and reporting the reason, and let a caller ask whether a directory can be read at all. See issue #1984. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
397 lines
14 KiB
C#
397 lines
14 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// "txt" => ".txt"
|
|
/// <br />".txt" => ".txt"
|
|
/// <br />null or whitespace => ""
|
|
/// </summary>
|
|
[return: NotNull]
|
|
public static string GetStandardizedExtension(string? extension)
|
|
=> string.IsNullOrWhiteSpace(extension)
|
|
? string.Empty
|
|
: '.' + extension.Trim().Trim('.');
|
|
|
|
/// <summary>
|
|
/// Return position with correct number of leading zeros.
|
|
/// <br />- 2 of 9 => "2"
|
|
/// <br />- 2 of 90 => "02"
|
|
/// <br />- 2 of 900 => "002"
|
|
/// </summary>
|
|
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
|
|
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
|
|
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');
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Ensure valid file name path:
|
|
/// <br/>- remove invalid chars
|
|
/// <br/>- ensure uniqueness
|
|
/// <br/>- enforce max file length
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/'</summary>
|
|
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);
|
|
|
|
/// <summary>no part of the path may begin or end in whitespace</summary>
|
|
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];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Move file.
|
|
/// <br/>- Ensure valid file name path: remove invalid chars, enforce max file length
|
|
/// <br/>- Perform <see cref="SaferMove"/>
|
|
/// </summary>
|
|
/// <param name="source">Name of the file to move</param>
|
|
/// <param name="destination">The new path and name for the file.</param>
|
|
/// <param name="replacements">Rules for replacing illegal file path characters</param>
|
|
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
|
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
|
/// <returns>The actual destination filename</returns>
|
|
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<Exception>()
|
|
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
|
|
|
|
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
|
public static void SaferDelete(LongPath source)
|
|
=> retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: true));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
|
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;
|
|
}
|
|
});
|
|
|
|
/// <summary>
|
|
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
|
/// </summary>
|
|
/// <param name="rootPath">Starting directory</param>
|
|
/// <param name="patternMatch">Filename pattern match</param>
|
|
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
|
/// <param name="onIncomplete">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.</param>
|
|
/// <returns>List of files</returns>
|
|
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action<Exception>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>
|
|
/// <see cref="EnumerationOptions.IgnoreInaccessible"/> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static IEnumerable<LongPath> IterateSafely(Func<IEnumerable<LongPath>> getSequence, LongPath path, Action<Exception>? onIncomplete = null)
|
|
{
|
|
IEnumerator<LongPath> 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<Exception>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a subdirectory or subdirectories on the specified path.
|
|
/// The specified path can be relative to this instance of the <see cref="DirectoryInfo"/> class.
|
|
/// <para/>
|
|
/// Fixes an issue with <see cref="DirectoryInfo.CreateSubdirectory(string)"/> where it fails when the parent <see cref="DirectoryInfo"/> is a drive root.
|
|
/// </summary>
|
|
/// <param name="path">The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name.</param>
|
|
/// <returns>The last directory specified in <paramref name="path"/></returns>
|
|
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;
|
|
}
|
|
}
|