namespace Sunfish.Shares;
public enum FileOpResult { Ok, InvalidName, NotFound, Exists, NoSpace, Conflict, Failed }
/// <summary>
/// Cambios en disco comunes a web, FTP y WebDAV. Los nombres ya llegan validados por la capa de
/// protocolo, pero se vuelven a comprobar aqui. Las subidas se escriben en un temporal oculto de
/// la misma carpeta y se renombran al terminar: una subida cortada no deja un fichero a medias
/// ni pisa el existente.
/// </summary>
public static class FileOperations
{
public const string TempPrefix = ".sunfish-";
/// <summary>Nombre aceptable para crear: valido en el sistema y que no empiece por '.'.</summary>
public static bool IsCreatableName(string name) =>
PathRules.IsValidSegment(name) && !name.StartsWith('.') && name.Length <= 255;
/// <summary>
/// Metadatos que macOS escribe en volumenes sin forks (._nombre = AppleDouble, .DS_Store).
/// Por WebDAV se aceptan aunque empiecen por '.'; no salen en los listados.
/// </summary>
public static bool IsMacMetadataName(string name) =>
PathRules.IsValidSegment(name) && name.Length <= 255 &&
((name.StartsWith("._", StringComparison.Ordinal) && name.Length > 2) || name == ".DS_Store") &&
!name.StartsWith(TempPrefix, StringComparison.Ordinal);
/// <summary>
/// Nombre de un fichero subido: sin la ruta que envian algunos navegadores (IE antiguo manda
/// "C:\WINDOWS\a.txt"; Mac clasico puede usar ':') y sin espacios sobrantes.
/// </summary>
public static string CleanUploadName(string fileName)
{
int cut = fileName.LastIndexOfAny(['\\', '/', ':']);
return (cut >= 0 ? fileName[(cut + 1)..] : fileName).Trim();
}
public static FileOpResult CreateFolder(string directory, string name)
{
if (!IsCreatableName(name))
return FileOpResult.InvalidName;
string target = Path.Combine(directory, name);
if (Directory.Exists(target) || File.Exists(target))
return FileOpResult.Exists;
try
{
Directory.CreateDirectory(target);
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>Borra un fichero o una carpeta con todo su contenido (los enlaces se borran, no se siguen).</summary>
public static FileOpResult Delete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
DeleteCompanion(path);
}
else if (Directory.Exists(path))
Directory.Delete(path, recursive: true);
else
return FileOpResult.NotFound;
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>Cambia el nombre dentro de la misma carpeta; no sustituye nada existente.</summary>
public static FileOpResult Rename(string path, string newName)
{
if (!IsCreatableName(newName))
return FileOpResult.InvalidName;
string? directory = Path.GetDirectoryName(path);
if (directory is null)
return FileOpResult.Failed;
string target = Path.Combine(directory, newName);
bool sameItem = string.Equals(target, path, StringComparison.OrdinalIgnoreCase);
if (!sameItem && (File.Exists(target) || Directory.Exists(target)))
return FileOpResult.Exists;
try
{
if (File.Exists(path))
{
File.Move(path, target);
MoveCompanion(path, target);
}
else if (Directory.Exists(path))
Directory.Move(path, target); // tambien vale para cambiar solo mayusculas en Windows
else
return FileOpResult.NotFound;
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>
/// Mueve (o renombra) a otra ruta ya validada del mismo share; no sustituye nada existente.
/// No mueve una carpeta dentro de si misma.
/// </summary>
public static FileOpResult Move(string source, string target)
{
string? name = Path.GetFileName(target);
if (name is null || !IsCreatableName(name))
return FileOpResult.InvalidName;
bool sameItem = string.Equals(source, target, StringComparison.OrdinalIgnoreCase);
if (!sameItem && (File.Exists(target) || Directory.Exists(target)))
return FileOpResult.Exists;
string? parent = Path.GetDirectoryName(target);
if (parent is null || !Directory.Exists(parent))
return FileOpResult.NotFound;
try
{
if (File.Exists(source))
{
File.Move(source, target);
MoveCompanion(source, target);
}
else if (Directory.Exists(source))
{
if (target.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return FileOpResult.Conflict;
Directory.Move(source, target);
}
else
return FileOpResult.NotFound;
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>Borra una carpeta solo si esta vacia (RMD de FTP).</summary>
public static FileOpResult DeleteEmptyFolder(string path)
{
if (!Directory.Exists(path))
return FileOpResult.NotFound;
if (Directory.EnumerateFileSystemEntries(path).Any())
return FileOpResult.Conflict;
try
{
Directory.Delete(path);
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>Hay sitio para <paramref name="bytes"/> en la unidad de la carpeta (si se puede saber).</summary>
public static bool HasSpace(string directory, long bytes)
{
try
{
string? root = Path.GetPathRoot(Path.GetFullPath(directory));
return root is null || new DriveInfo(root).AvailableFreeSpace >= bytes;
}
catch (Exception e) when (e is IOException or ArgumentException or UnauthorizedAccessException)
{
return true;
}
}
/// <summary>Guarda un flujo como fichero nuevo (o sustituye el existente si se permite).</summary>
public static async Task<FileOpResult> SaveAsync(string directory, string name, Stream content, bool overwrite, CancellationToken ct, bool allowMacMetadata = false)
{
if (!IsCreatableName(name) && !(allowMacMetadata && IsMacMetadataName(name)))
return FileOpResult.InvalidName;
string target = Path.Combine(directory, name);
if (Directory.Exists(target))
return FileOpResult.Exists;
if (File.Exists(target) && !overwrite)
return FileOpResult.Exists;
string temp = Path.Combine(directory, TempPrefix + Guid.NewGuid().ToString("N") + ".tmp");
try
{
await using (var file = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true))
await content.CopyToAsync(file, 65536, ct);
return Commit(temp, target, overwrite);
}
catch
{
TryDelete(temp);
throw;
}
}
/// <summary>
/// Escribe un trozo de una subida por partes (PUT con Content-Range). El temporal se identifica
/// por el id que manda el cliente; cada trozo debe empezar donde acabo el anterior. Con el
/// ultimo trozo el fichero se renombra a su sitio.
/// </summary>
public static async Task<(FileOpResult Result, bool Completed)> WritePartAsync(
string directory, string name, string uploadId, long start, long total, Stream content, bool overwrite, CancellationToken ct)
{
if (!IsCreatableName(name))
return (FileOpResult.InvalidName, false);
string target = Path.Combine(directory, name);
if (Directory.Exists(target) || (File.Exists(target) && !overwrite))
return (FileOpResult.Exists, false);
string temp = Path.Combine(directory, TempPrefix + uploadId + ".part");
long length;
await using (var file = new FileStream(temp, start == 0 ? FileMode.Create : FileMode.Open, FileAccess.Write, FileShare.None, 65536, useAsync: true))
{
if (file.Length != start)
return (FileOpResult.Conflict, false);
file.Position = start;
await content.CopyToAsync(file, 65536, ct);
length = file.Length;
}
if (length > total)
{
TryDelete(temp);
return (FileOpResult.Conflict, false);
}
if (length < total)
return (FileOpResult.Ok, false);
return (Commit(temp, target, overwrite), true);
}
private static FileOpResult Commit(string temp, string target, bool overwrite)
{
try
{
File.Move(temp, target, overwrite);
return FileOpResult.Ok;
}
catch (IOException) when (File.Exists(target) && !overwrite)
{
TryDelete(temp);
return FileOpResult.Exists;
}
}
/// <summary>
/// El AppleDouble ("._nombre") de un fichero de Mac acompana siempre a su fichero: se mueve y
/// se borra con el.
/// </summary>
private static string CompanionOf(string path) =>
Path.Combine(Path.GetDirectoryName(path) ?? "", "._" + Path.GetFileName(path));
private static void MoveCompanion(string source, string target)
{
if (Path.GetFileName(source).StartsWith("._", StringComparison.Ordinal))
return;
string from = CompanionOf(source);
if (File.Exists(from))
{
try { File.Move(from, CompanionOf(target), overwrite: true); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
}
}
private static void DeleteCompanion(string path)
{
if (!Path.GetFileName(path).StartsWith("._", StringComparison.Ordinal))
TryDelete(CompanionOf(path));
}
private static void TryDelete(string path)
{
try { File.Delete(path); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
}
}