using Sunfish.Config;
namespace Sunfish.Shares;
/// <summary>Una entrada de un listado (real o virtual).</summary>
public sealed record FileEntry(string Name, bool IsDirectory, long Size, DateTime Modified, bool IsVirtual = false, bool IsLink = false);
/// <summary>
/// Acceso al disco de un share. Toda ruta pasa por <see cref="TryGetPhysicalPath"/>, que garantiza
/// que no se sale de la carpeta del share (ni con "..", ni con rutas absolutas, ni con enlaces).
/// </summary>
public static class ShareFileSystem
{
private static readonly StringComparison PathComparison =
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
public static string RootOf(ShareConfig share) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(share.Path));
/// <summary>Ruta real dentro del share, o null si los componentes no son validos o escaparian de el.</summary>
public static string? TryGetPhysicalPath(ShareConfig share, IReadOnlyList<string> relative, bool aliases = false)
{
string root = RootOf(share);
string current = root;
foreach (var segment in relative)
{
if (!PathRules.IsValidSegment(segment))
return null;
string next = Path.Combine(current, segment);
// Alias 8.3 (DOS, Windows 3.1): solo si el nombre tal cual no existe.
if (aliases && !File.Exists(next) && !Directory.Exists(next) &&
ShortNames.Resolve(current, segment) is { } real)
next = Path.Combine(current, real);
current = next;
if (!IsInside(root, Path.GetFullPath(current)))
return null;
// Un enlace (simbolico o junction) solo se sigue si su destino final sigue dentro.
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (info.Exists && info.LinkTarget is not null)
{
var target = info.ResolveLinkTarget(returnFinalTarget: true);
if (target is null || !IsInside(root, Path.GetFullPath(target.FullName)))
return null;
}
}
return current;
}
public static bool IsInside(string root, string fullPath)
{
if (string.Equals(root, fullPath, PathComparison))
return true;
string prefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar;
return fullPath.StartsWith(prefix, PathComparison);
}
/// <summary>
/// Contenido de una carpeta para listar. Se omiten los ocultos (atributo Hidden/System en
/// Windows, nombres que empiezan por '.') aunque se puedan pedir directamente por su URL.
/// </summary>
public static List<FileEntry> List(string physicalDirectory)
{
var entries = new List<FileEntry>();
var dir = new DirectoryInfo(physicalDirectory);
foreach (var info in dir.EnumerateFileSystemInfos())
{
if (IsHidden(info))
continue;
bool link = info.LinkTarget is not null;
if (info is DirectoryInfo)
entries.Add(new FileEntry(info.Name, true, 0, info.LastWriteTime, IsLink: link));
else if (info is FileInfo file)
entries.Add(new FileEntry(file.Name, false, file.Length, file.LastWriteTime, IsLink: link));
}
return entries;
}
private static bool IsHidden(FileSystemInfo info) =>
info.Name.StartsWith('.') || info.Name == "Icon\r" || // icono de carpeta de Mac
(info.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0;
}