Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions PhotoLocator/Helpers/DragDropFileExtractor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using OpenCvSharp.Dnn;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Windows;

namespace PhotoLocator.Helpers
{
static class DragDropFileExtractor
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct FILEDESCRIPTOR
{
public uint dwFlags;
public Guid clsid;
public System.Drawing.Size sizel;
public System.Drawing.Point pointl;
public uint dwFileAttributes;
public FILETIME ftCreationTime;
public FILETIME ftLastAccessTime;
public FILETIME ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
}

/// <summary>
/// Try to extract files from a drag-drop data object. Supports FileDrop and virtual file formats used by cameras (FileGroupDescriptor / FileContents).
/// Returns saved file paths when any files were extracted.
/// </summary>
public static List<string>? TryExtractFiles(System.Windows.IDataObject data, string targetDirectory, Func<string,bool> overwriteCheck)
{
// Standard file drop
if (data.GetDataPresent(DataFormats.FileDrop))
{
var saved = new List<string>();
var droppedObj = data.GetData(DataFormats.FileDrop);
if (droppedObj is string[] dropped && dropped.Length > 0)
{
foreach (var sourceFileName in dropped)
{
var targetPath = Path.Combine(targetDirectory, Path.GetFileName(sourceFileName));
if (sourceFileName == targetPath || File.Exists(targetPath) && !overwriteCheck(targetPath))
continue;
File.Copy(sourceFileName, targetPath, true);
saved.Add(targetPath);
}
return saved;
}
}

// Look for FileGroupDescriptorW or FileGroupDescriptor
var formats = data.GetFormats(true);
var fgFormat = formats.Contains("FileGroupDescriptorW") ? "FileGroupDescriptorW" : formats.Contains("FileGroupDescriptor") ? "FileGroupDescriptor" : null;
if (fgFormat is null)
return null;

// Read FILEGROUPDESCRIPTOR bytes
var fgObj = data.GetData(fgFormat);
if (fgObj is null) return null;
byte[] bytes;
if (fgObj is MemoryStream fgStream)
bytes = fgStream.ToArray();
else if (fgObj is byte[] b)
bytes = b;
else if (fgObj is UnmanagedMemoryStream ums)
{
bytes = new byte[ums.Length];
ums.Read(bytes, 0, bytes.Length);
}
else
return null;

var fileNames = new List<string>();
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
var ptr = handle.AddrOfPinnedObject();
int count = Marshal.ReadInt32(ptr);
var descSize = Marshal.SizeOf<FILEDESCRIPTOR>();
for (int i = 0; i < count; i++)
{
var itemPtr = IntPtr.Add(ptr, 4 + i * descSize);
var fd = Marshal.PtrToStructure<FILEDESCRIPTOR>(itemPtr);
fileNames.Add(fd.cFileName);
}
}
finally
{
handle.Free();
}

if (fileNames.Count == 0) return null;

// Obtain COM IDataObject
var unk = Marshal.GetIUnknownForObject(data);
try
{
var iid = typeof(System.Runtime.InteropServices.ComTypes.IDataObject).GUID;
Marshal.QueryInterface(unk, in iid, out var pDataObj);
if (pDataObj == IntPtr.Zero) return null;
try
{
var comData = (System.Runtime.InteropServices.ComTypes.IDataObject)Marshal.GetObjectForIUnknown(pDataObj);
// Clipboard format id for FileContents
var fileContentsId = System.Windows.Forms.DataFormats.GetFormat("FileContents").Id;

var saved = new List<string>();
for (int index = 0; index < fileNames.Count; index++)
{
var fmt = new FORMATETC
{
cfFormat = (short)fileContentsId,
ptd = IntPtr.Zero,
dwAspect = DVASPECT.DVASPECT_CONTENT,
lindex = index,
tymed = TYMED.TYMED_ISTREAM | TYMED.TYMED_HGLOBAL
};

var medium = new STGMEDIUM();
try
{
comData.GetData(ref fmt, out medium);
}
catch
{
continue;
}

try
{
var fileName = fileNames[index];
var targetPath = Path.Combine(targetDirectory, fileName);
if (File.Exists(targetPath) && !overwriteCheck(targetPath))
continue;
Comment on lines +136 to +139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Sanitize virtual filenames before combining paths.

fileName comes from the drag source. Path.Combine(targetDirectory, fileName) will honor rooted paths and .. segments, so a malicious provider can write outside the photo folder. Strip it to a basename and verify the resolved path still stays under targetDirectory.

🔒 Suggested fix
-                            var fileName = fileNames[index];
-                            var targetPath = Path.Combine(targetDirectory, fileName);
+                            var fileName = Path.GetFileName(fileNames[index]);
+                            if (string.IsNullOrWhiteSpace(fileName))
+                                continue;
+
+                            var targetRoot = Path.GetFullPath(targetDirectory)
+                                .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+                                + Path.DirectorySeparatorChar;
+                            var targetPath = Path.GetFullPath(Path.Combine(targetRoot, fileName));
+                            if (!targetPath.StartsWith(targetRoot, StringComparison.OrdinalIgnoreCase))
+                                continue;
                             if (File.Exists(targetPath) && !overwriteCheck(targetPath))
                                 continue;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var fileName = fileNames[index];
var targetPath = Path.Combine(targetDirectory, fileName);
if (File.Exists(targetPath) && !overwriteCheck(targetPath))
continue;
var fileName = Path.GetFileName(fileNames[index]);
if (string.IsNullOrWhiteSpace(fileName))
continue;
var targetRoot = Path.GetFullPath(targetDirectory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
Path.DirectorySeparatorChar;
var targetPath = Path.GetFullPath(Path.Combine(targetRoot, fileName));
if (!targetPath.StartsWith(targetRoot, StringComparison.OrdinalIgnoreCase))
continue;
if (File.Exists(targetPath) && !overwriteCheck(targetPath))
continue;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PhotoLocator/Helpers/DragDropFileExtractor.cs` around lines 136 - 139,
Sanitize the incoming fileName from the drag source before combining: replace
fileName with its basename (use Path.GetFileName on the value used in
DragDropFileExtractor) to strip rooted paths and .. segments, then compute
targetPath with Path.Combine(targetDirectory, basename) and resolve using
Path.GetFullPath; after resolving ensure the resulting full targetPath is still
under the intended targetDirectory full path (compare FullPath prefixes with a
directory separator check) before proceeding to overwriteCheck and file
creation.


// IStream
if (((int)medium.tymed & (int)TYMED.TYMED_ISTREAM) != 0 && medium.unionmember != IntPtr.Zero)
{
var comStream = (IStream)Marshal.GetObjectForIUnknown(medium.unionmember);
using var outFs = File.Create(targetPath);
CopyIStreamToStream(comStream, outFs);
saved.Add(targetPath);
}
else if (((int)medium.tymed & (int)TYMED.TYMED_HGLOBAL) != 0 && medium.unionmember != IntPtr.Zero)
{
// HGLOBAL: lock and copy
var hglobal = medium.unionmember;
var ptrData = GlobalLock(hglobal);
try
{
// We don't have size here; attempt to write until null or best-effort using FILEDESCRIPTOR values
// As a fallback, create file from bytes until GlobalSize
var globalSize = GlobalSize(hglobal);
var buffer = new byte[globalSize];
Marshal.Copy(ptrData, buffer, 0, buffer.Length);
File.WriteAllBytes(targetPath, buffer);
saved.Add(targetPath);
}
finally
{
GlobalUnlock(hglobal);
}
}
}
finally
{
ReleaseStgMedium(ref medium);
}
}

return saved.Count > 0 ? saved : null;
}
finally
{
Marshal.Release(pDataObj);
}
}
finally
{
Marshal.Release(unk);
}
}

static void CopyIStreamToStream(IStream comStream, Stream outStream)
{
const int chunk = 64 * 1024;
var buffer = new byte[chunk];
var pcbRead = Marshal.AllocCoTaskMem(sizeof(int));
try
{
while (true)
{
comStream.Read(buffer, buffer.Length, pcbRead);
int read = Marshal.ReadInt32(pcbRead);
if (read == 0) break;
outStream.Write(buffer, 0, read);
}
}
finally
{
Marshal.FreeCoTaskMem(pcbRead);
}
}

[DllImport("kernel32.dll", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
static extern IntPtr GlobalLock(IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool GlobalUnlock(IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
static extern int GlobalSize(IntPtr hMem);

[DllImport("ole32.dll"), DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
static extern void ReleaseStgMedium(ref STGMEDIUM pmedium);
}
}
42 changes: 11 additions & 31 deletions PhotoLocator/Helpers/ShellContextMenu.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#nullable disable
#pragma warning disable CA1032
#pragma warning disable CA1069
#pragma warning disable CA1401
#pragma warning disable CA1707
#pragma warning disable CA1711
#pragma warning disable CA1805
Expand Down Expand Up @@ -76,7 +75,7 @@ private bool GetContextMenuInterfaces(IShellFolder oParentFolder, IntPtr[] arrPI
IntPtr.Zero,
(uint)arrPIDLs.Length,
arrPIDLs,
ref IID_IContextMenu,
in IID_IContextMenu,
IntPtr.Zero,
ref ctxMenuPtr);

Expand Down Expand Up @@ -106,22 +105,6 @@ private bool GetContextMenuInterfaces(IShellFolder oParentFolder, IntPtr[] arrPI
/// <returns>true if the message has been handled, false otherwise</returns>
protected override void WndProc(ref Message m)
{
#region IContextMenu

if (_oContextMenu != null &&
m.Msg == (int)WM.MENUSELECT &&
((int)ShellHelper.HiWord(m.WParam) & (int)MFT.SEPARATOR) == 0 &&
((int)ShellHelper.HiWord(m.WParam) & (int)MFT.POPUP) == 0)
{
string info;
if (ShellHelper.LoWord(m.WParam) == (int)CMD_CUSTOM.ExpandCollapse)
info = "Expands or collapses the current selected item";
else
info = string.Empty;
}

#endregion

#region IContextMenu2

if (_oContextMenu2 != null &&
Expand Down Expand Up @@ -263,22 +246,19 @@ private IShellFolder GetParentFolder(string folderName)

IntPtr pStrRet = Marshal.AllocCoTaskMem(MAX_PATH * 2 + 4);
Marshal.WriteInt32(pStrRet, 0, 0);
nResult = _oDesktopFolder.GetDisplayNameOf(pPIDL, SHGNO.FORPARSING, pStrRet);
_oDesktopFolder.GetDisplayNameOf(pPIDL, SHGNO.FORPARSING, pStrRet);
var strFolder = new StringBuilder(MAX_PATH);
StrRetToBuf(pStrRet, pPIDL, strFolder, MAX_PATH);
Marshal.FreeCoTaskMem(pStrRet);
pStrRet = IntPtr.Zero;
_strParentFolder = strFolder.ToString();

// Get the IShellFolder for folder
IntPtr pUnknownParentFolder = IntPtr.Zero;
nResult = oDesktopFolder.BindToObject(pPIDL, IntPtr.Zero, ref IID_IShellFolder, ref pUnknownParentFolder);
nResult = oDesktopFolder.BindToObject(pPIDL, IntPtr.Zero, in IID_IShellFolder, ref pUnknownParentFolder);
// Free the PIDL first
Marshal.FreeCoTaskMem(pPIDL);
if (S_OK != nResult)
{
return null;
}
_oParentFolder = (IShellFolder)Marshal.GetTypedObjectForIUnknown(pUnknownParentFolder, typeof(IShellFolder));
}

Expand Down Expand Up @@ -307,7 +287,7 @@ protected IntPtr[] GetPIDLs(FileInfo[] arrFI)

IntPtr[] arrPIDLs = new IntPtr[arrFI.Length];
int n = 0;
foreach (FileInfo fi in arrFI)
foreach (var fi in arrFI)
{
// Get the file relative to folder
uint pchEaten = 0;
Expand Down Expand Up @@ -346,7 +326,7 @@ protected IntPtr[] GetPIDLs(DirectoryInfo[] arrFI)

IntPtr[] arrPIDLs = new IntPtr[arrFI.Length];
int n = 0;
foreach (DirectoryInfo fi in arrFI)
foreach (var fi in arrFI)
{
// Get the file relative to folder
uint pchEaten = 0;
Expand Down Expand Up @@ -612,10 +592,10 @@ private void ShowContextMenu(Point pointScreen)

#region Shell GUIDs

private static Guid IID_IShellFolder = new("{000214E6-0000-0000-C000-000000000046}");
private static Guid IID_IContextMenu = new("{000214e4-0000-0000-c000-000000000046}");
private static Guid IID_IContextMenu2 = new("{000214f4-0000-0000-c000-000000000046}");
private static Guid IID_IContextMenu3 = new("{bcfce0a0-ec17-11d0-8d10-00a0c90f2719}");
private static readonly Guid IID_IShellFolder = new("{000214E6-0000-0000-C000-000000000046}");
private static readonly Guid IID_IContextMenu = new("{000214e4-0000-0000-c000-000000000046}");
private static readonly Guid IID_IContextMenu2 = new("{000214f4-0000-0000-c000-000000000046}");
private static readonly Guid IID_IContextMenu3 = new("{bcfce0a0-ec17-11d0-8d10-00a0c90f2719}");

#endregion

Expand Down Expand Up @@ -1198,7 +1178,7 @@ Int32 EnumObjects(
Int32 BindToObject(
IntPtr pidl,
IntPtr pbc,
ref Guid riid,
in Guid riid,
ref IntPtr ppv);

// Requests a pointer to an object's storage interface.
Expand Down Expand Up @@ -1253,7 +1233,7 @@ Int32 GetUIObjectOf(
uint cidl,
[MarshalAs(UnmanagedType.LPArray)]
IntPtr[] apidl,
ref Guid riid,
in Guid riid,
IntPtr rgfReserved,
ref IntPtr ppv);

Expand Down
29 changes: 26 additions & 3 deletions PhotoLocator/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,33 @@ private void HandlePathEditPreviewKeyUp(object sender, KeyEventArgs e)

private void HandleDrop(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] droppedEntries && droppedEntries.Length > 0
&& !droppedEntries.Equals(_draggedFiles))
if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] droppedEntries && droppedEntries.Length > 0)
{
Dispatcher.BeginInvoke(() => _viewModel.HandleDroppedFiles(droppedEntries));
if (!droppedEntries.Equals(_draggedFiles))
Dispatcher.BeginInvoke(() => _viewModel.HandleDroppedFiles(droppedEntries));
Comment on lines +322 to +325
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Equals is only checking array identity here.

_draggedFiles is populated from the current selection in HandleFileItemMouseMove (PhotoLocator/MainWindow.xaml.cs:296-298), so this guard is the only thing preventing a self-drop from reopening SelectDropActionWindow (PhotoLocator/MainViewModel.cs:708-712). string[].Equals only succeeds for the same array instance, not the same paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PhotoLocator/MainWindow.xaml.cs` around lines 322 - 325, The current guard
uses array identity (droppedEntries.Equals(_draggedFiles)) so self-drops with
different array instances still pass; change the check in the drop handler to
compare contents instead of identity (e.g., use Enumerable.SequenceEqual or a
path-aware comparison) to compare droppedEntries to _draggedFiles before calling
Dispatcher.BeginInvoke(() => _viewModel.HandleDroppedFiles(droppedEntries));
ensure the comparison uses the same comparison semantics as
HandleFileItemMouseMove (case-insensitive path comparison if file paths are
normalized) so a self-drop will be correctly skipped and SelectDropActionWindow
won't reopen.

return;
}
if (string.IsNullOrEmpty(_viewModel.PhotoFolderPath))
return;
try
{
using var cursor = new MouseCursorOverride();
var extracted = DragDropFileExtractor.TryExtractFiles(e.Data, _viewModel.PhotoFolderPath,
existingFile =>
{
return MessageBox.Show(this, $"The file '{existingFile}' already exists. Do you wish to overwrite it?", "Copy files", MessageBoxButton.YesNoCancel, MessageBoxImage.Question) switch
{
MessageBoxResult.Yes => true,
MessageBoxResult.No => false,
_ => throw new OperationCanceledException(),
};
});
if (extracted is not null && extracted.Count > 0)
Dispatcher.BeginInvoke(() => _viewModel.SelectFileAsync(extracted[0]));
}
catch (Exception ex)
{
ExceptionHandler.LogException(ex);
}
}

Expand Down
Loading