Files
fluid-explorer/Services/Operations/RecycleBinLocator.cs
T

354 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.InteropServices;
using System.Security.Principal;
namespace FluidExplorer.Services.Operations;
/// <summary>回收站里一条 $I 索引记录解析出来的信息。</summary>
internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
/// <summary>
/// 回收站定位器:
/// 1. 用 shell32!SHFileOperationW(FO_DELETE + FOF_ALLOWUNDO) 把一批路径送进回收站(一次调用完成一批);
/// 2. 通过"操作前后 &lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
/// 从而支持 Ctrl+Z 一步还原。
///
/// $I 文件结构(Win10+ 为版本 2):
/// offset 0 8B 版本号(Win10+ = 2)
/// offset 8 8B 原始文件大小
/// offset 16 8B 删除时间(FILETIME)
/// offset 24 4B 文件名长度(仅版本 &gt;= 2 存在)
/// offset 24/28 UTF-16LE 的原始完整路径,以 \0 结尾
/// 路径长度字段在不同 Windows 版本上语义有歧义(字符数 / 字节数两种实现都有),
/// 因此这里直接读到缓冲区末尾并按第一个 \0 截断,比依赖该字段更稳。
/// </summary>
internal static partial class RecycleBinLocator
{
private const uint FO_DELETE = 0x0003;
private const ushort FOF_SILENT = 0x0004;
private const ushort FOF_NOCONFIRMATION = 0x0010;
private const ushort FOF_ALLOWUNDO = 0x0040;
private const ushort FOF_NOERRORUI = 0x0400;
private const ushort FOF_WANTNUKEWARNING = 0x4000;
private const ushort DeleteFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING;
[StructLayout(LayoutKind.Sequential)]
private struct SHFILEOPSTRUCTW
{
public IntPtr hwnd;
public uint wFunc;
public IntPtr pFrom;
public IntPtr pTo;
public ushort fFlags;
public int fAnyOperationsAborted;
public IntPtr hNameMappings;
public IntPtr lpszProgressTitle;
}
// 用传统 DllImport:结构体全是 blittable 字段,不需要 LibraryImport 的 unsafe 代码生成,
// 这样本层不引入 AllowUnsafeBlocks 依赖,任何项目链接这些源码都能直接编译。
[DllImport("shell32.dll", EntryPoint = "SHFileOperationW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern int SHFileOperation(ref SHFILEOPSTRUCTW lpFileOp);
// ------------------------------------------------------------ 删除到回收站
/// <summary>把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。</summary>
internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList<string> paths)
{
if (paths.Count == 0) return (true, false, 0);
// 注意:Shell API 只接受普通路径,绝不能带 \\?\ 前缀。
var from = PathHelper.AllocDoubleNullList(paths.Select(PathHelper.ToShellPath));
var op = new SHFILEOPSTRUCTW
{
hwnd = IntPtr.Zero,
wFunc = FO_DELETE,
pFrom = from,
pTo = IntPtr.Zero,
fFlags = DeleteFlags,
fAnyOperationsAborted = 0,
hNameMappings = IntPtr.Zero,
lpszProgressTitle = IntPtr.Zero
};
try
{
var code = SHFileOperation(ref op);
var aborted = op.fAnyOperationsAborted != 0;
return (code == 0 && !aborted, aborted, code);
}
finally
{
Marshal.FreeHGlobal(from);
}
}
/// <summary>
/// 在专用 STA 线程上执行 SHFileOperationW。
/// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
/// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
/// </summary>
internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList<string> paths)
{
var tcs = new TaskCompletionSource<(bool Success, bool Aborted, int Code)>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
tcs.TrySetResult(DeleteToRecycleBin(paths));
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
})
{
IsBackground = true,
Name = "FluidExplorer-RecycleBin"
};
try
{
thread.SetApartmentState(ApartmentState.STA);
}
catch (PlatformNotSupportedException)
{
// 非 Windows 平台(本引擎实际只跑 Windows):直接以 MTA 启动。
}
thread.Start();
return tcs.Task;
}
internal static string DescribeResult(int code) => code switch
{
0 => "成功",
2 => "找不到指定的文件。",
3 => "找不到指定的路径。",
5 => "拒绝访问。",
0x20 => "共享冲突(文件正被其它进程使用)。",
0x71 => "源与目标是同一个文件。",
0x72 => "多个源文件对应单个目标(目录)。",
0x73 => "源与目标处于不同目录。",
0x74 => "不能对根目录执行该操作。",
0x75 => "操作已被取消。",
0x76 => "目标位于源的子树中。",
0x78 => "访问源文件被拒绝。",
0x79 => "路径层级过深。",
0x7A => "目标过多。",
0x7C => "存在无效文件名。",
0x7D => "目标与源在同一目录树内。",
0x7E => "目标为文件,但源为文件夹。",
0x80 => "目标为文件夹,但源为文件。",
0x81 => "文件名过长。",
0x82 => "目标磁盘为 CD-ROM。",
0x83 => "目标磁盘为 DVD。",
0x84 => "目标磁盘为可刻录光盘。",
0x85 => "文件过大。",
0x86 => "源磁盘为 CD-ROM。",
0x87 => "源磁盘为 DVD。",
0x88 => "源磁盘为可刻录光盘。",
0xB7 => "超过文件名/路径长度上限。",
0x10000 => "目标上发生未指明的错误。",
_ => $"SHFileOperation 返回错误码 0x{code:X}。"
};
// ------------------------------------------------------------ $I / $R 定位
/// <summary>取当前进程用户的 SID 字符串(回收站目录名)。</summary>
internal static string? TryGetCurrentUserSid()
{
try
{
using var identity = WindowsIdentity.GetCurrent();
return identity.User?.Value;
}
catch (Exception)
{
return null;
}
}
/// <summary>某个卷的回收站目录:&lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\(不存在返回 null)。</summary>
internal static string? GetRecycleBinDirectory(string volumeRoot)
{
var sid = TryGetCurrentUserSid();
if (string.IsNullOrEmpty(sid)) return null;
var dir = PathHelper.Combine(PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"), sid);
return PathHelper.DirectoryExists(dir) ? dir : null;
}
/// <summary>
/// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
/// 主体扫描 &lt;卷&gt;:\$Recycle.Bin\&lt;当前用户 SID&gt;\,同时兜底扫描其它 SID 目录
/// (进程可能以别的账户删除过文件)。
/// </summary>
internal static Dictionary<string, HashSet<string>> CaptureState(IReadOnlyList<string> paths)
{
var volumes = new List<string>();
foreach (var path in paths)
{
var root = PathHelper.GetVolumeRoot(path);
if (root.Length < 2) continue;
if (!volumes.Contains(root, StringComparer.OrdinalIgnoreCase)) volumes.Add(root);
}
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
return result;
}
private static HashSet<string> EnumerateIndexFiles(string volumeRoot)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
if (!PathHelper.DirectoryExists(binRoot)) return set;
var directories = new List<string>();
var ownSidDirectory = GetRecycleBinDirectory(volumeRoot);
if (ownSidDirectory is not null) directories.Add(ownSidDirectory);
foreach (var sub in PathHelper.EnumerateChildrenSafe(binRoot))
{
if (!PathHelper.DirectoryExists(sub)) continue;
if (!directories.Contains(sub, StringComparer.OrdinalIgnoreCase)) directories.Add(sub);
}
foreach (var directory in directories)
{
foreach (var file in PathHelper.EnumerateChildrenSafe(directory))
{
var name = PathHelper.GetFileName(file);
if (name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) set.Add(file);
}
}
return set;
}
/// <summary>
/// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
/// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 <see cref="UndoEntry.Deleted"/>。
/// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
/// </summary>
internal static (List<(string RecyclePath, string OriginalPath)> Items, List<string> Diagnostics) ResolveDeletedItems(
IReadOnlyList<string> deletedPaths,
Dictionary<string, HashSet<string>> before)
{
var items = new List<(string RecyclePath, string OriginalPath)>();
var diagnostics = new List<string>();
var remaining = new List<string>(deletedPaths);
foreach (var (volume, previous) in before)
{
var current = EnumerateIndexFiles(volume);
var added = current.Where(f => !previous.Contains(f)).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
if (added.Count == 0) continue;
foreach (var indexFile in added)
{
var parsed = TryParseIndexFile(indexFile);
if (parsed is null)
{
diagnostics.Add($"$I 解析失败(长度/格式异常):{indexFile}");
continue;
}
// 匹配策略:先按原始完整路径精确匹配,再退化为"同名文件"匹配
// (一次 SHFileOperation 调用内完成的条目,时间窗天然一致)。
var match = remaining.FirstOrDefault(p => SamePath(p, parsed.OriginalPath))
?? remaining.FirstOrDefault(p => string.Equals(
PathHelper.GetFileName(p), PathHelper.GetFileName(parsed.OriginalPath), StringComparison.OrdinalIgnoreCase));
if (match is null)
{
diagnostics.Add($"回收站新增条目未能匹配本次删除路径:{indexFile}(原始路径 {parsed.OriginalPath})");
continue;
}
if (!PathHelper.Exists(parsed.DataPath))
diagnostics.Add($"找到 $I 记录但缺少对应的 $R 数据文件:{parsed.DataPath}");
items.Add((parsed.DataPath, match));
remaining.Remove(match);
}
}
foreach (var path in remaining)
{
diagnostics.Add($"未能在回收站定位到“{path}”的 $I/$R 记录(可能被永久删除或回收站不可用),撤销时该项将按“无法还原”处理。");
items.Add((string.Empty, path));
}
return (items, diagnostics);
}
/// <summary>解析单个 $I 索引文件。</summary>
internal static RecycleBinItem? TryParseIndexFile(string indexFilePath)
{
byte[] bytes;
try
{
bytes = File.ReadAllBytes(PathHelper.ToExtended(indexFilePath));
}
catch (Exception)
{
return null;
}
if (bytes.Length < 28) return null;
var version = BitConverter.ToInt64(bytes, 0);
var size = BitConverter.ToInt64(bytes, 8);
var fileTime = BitConverter.ToInt64(bytes, 16);
DateTime deletedUtc;
try
{
deletedUtc = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
}
catch (ArgumentOutOfRangeException)
{
deletedUtc = DateTime.MinValue;
}
// 版本 1(Vista/7)无文件名长度字段;版本 2(Win10+)多 4 字节。
var pathOffset = version >= 2 ? 28 : 24;
if (bytes.Length <= pathOffset) return null;
var payload = bytes.AsSpan(pathOffset);
if ((payload.Length & 1) == 1) payload = payload[..^1]; // UTF-16 按 2 字节对齐
var chars = MemoryMarshal.Cast<byte, char>(payload);
var terminator = chars.IndexOf('\0');
if (terminator >= 0) chars = chars[..terminator];
if (chars.Length == 0) return null;
var originalPath = new string(chars);
var name = PathHelper.GetFileName(indexFilePath);
if (name.Length < 3 || !name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) return null;
// $R 对应文件:把 $I 换成 $R 前缀即为回收站内的实际数据路径(目录同样适用)。
var dataPath = PathHelper.Combine(PathHelper.GetDirectoryName(indexFilePath), "$R" + name[2..]);
return new RecycleBinItem(indexFilePath, dataPath, originalPath, size, deletedUtc);
}
/// <summary>由 $R 数据路径反推对应的 $I 索引路径。</summary>
internal static string? GetIndexPathFromDataPath(string recycleDataPath)
{
var name = PathHelper.GetFileName(recycleDataPath);
if (name.Length < 3 || !name.StartsWith("$R", StringComparison.OrdinalIgnoreCase)) return null;
return PathHelper.Combine(PathHelper.GetDirectoryName(recycleDataPath), "$I" + name[2..]);
}
private static bool SamePath(string a, string b)
{
var na = (PathHelper.TryGetFullPath(a) ?? a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var nb = (PathHelper.TryGetFullPath(b) ?? b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
}