Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Option to clone smaller instances with junction points (Windows) or symbolic links (Unix) #4129

Merged
merged 3 commits into from
Aug 2, 2024
Merged
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
7 changes: 5 additions & 2 deletions Cmdline/Action/GameInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ internal class AddOptions : CommonOptions

internal class CloneOptions : CommonOptions
{
[Option("share-stock", DefaultValue = false, HelpText = "Use junction points (Windows) or symbolic links (Unix) for stock dirs instead of copying")]
public bool shareStock { get; set; }

[ValueOption(0)] public string nameOrPath { get; set; }
[ValueOption(1)] public string new_name { get; set; }
[ValueOption(2)] public string new_path { get; set; }
Expand Down Expand Up @@ -341,7 +344,7 @@ private int CloneInstall(CloneOptions options)
if (instance.Name == instanceNameOrPath)
{
// Found it, now clone it.
Manager.CloneInstance(instance, newName, newPath);
Manager.CloneInstance(instance, newName, newPath, options.shareStock);
break;
}
}
Expand All @@ -350,7 +353,7 @@ private int CloneInstall(CloneOptions options)
// If it's valid, go on.
else if (Manager.InstanceAt(instanceNameOrPath) is CKAN.GameInstance instance && instance.Valid)
{
Manager.CloneInstance(instance, newName, newPath);
Manager.CloneInstance(instance, newName, newPath, options.shareStock);
}
// There is no instance with this name or at this path.
else
Expand Down
167 changes: 167 additions & 0 deletions Core/DirectoryLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;

using ChinhDo.Transactions.FileManager;

namespace CKAN
{
/// <summary>
/// Junctions on Windows, symbolic links on Unix
/// </summary>
public static class DirectoryLink
{
public static void Create(string target, string link, TxFileManager txMgr)
{
if (!CreateImpl(target, link, txMgr))
{
throw new Kraken(Platform.IsWindows
? $"Failed to create junction at {link}: {Marshal.GetLastWin32Error()}"
: $"Failed to create symbolic link at {link}");
}
}

private static bool CreateImpl(string target, string link, TxFileManager txMgr)
=> Platform.IsWindows ? CreateJunction(link, target, txMgr)
: symlink(target, link) == 0;

[DllImport("libc")]
private static extern int symlink(string target, string link);

private static bool CreateJunction(string link, string target, TxFileManager txMgr)
{
// A junction is a directory with some extra magic attached
if (!txMgr.DirectoryExists(link))
{
txMgr.CreateDirectory(link);
}
using (var h = CreateFile(link, GenericWrite, FileShare.Read | FileShare.Write, IntPtr.Zero,
FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero))
{
if (!h.IsInvalid)
{
var junctionInfo = ReparseDataBuffer.FromPath(target, out int byteCount);
return DeviceIoControl(h, FSCTL_SET_REPARSE_POINT,
ref junctionInfo, byteCount + 20,
null, 0,
out _, IntPtr.Zero);
}
}
return false;
}

public static bool TryGetTarget(string link, out string target)
{
target = null;
var fi = new DirectoryInfo(link);
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
{
if (Platform.IsWindows)
{
var h = CreateFile(link, 0, FileShare.Read, IntPtr.Zero,
FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero);
if (!h.IsInvalid)
{
if (DeviceIoControl(h, FSCTL_GET_REPARSE_POINT,
null, 0,
out ReparseDataBuffer junctionInfo, Marshal.SizeOf(typeof(ReparseDataBuffer)),
out _, IntPtr.Zero))
{
if (junctionInfo.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT)
{
target = junctionInfo.PathBuffer.TrimStart("\\\\?\\");
}
}
h.Close();
}
}
else
{
var bytes = new byte[1024];
var result = readlink(link, bytes, bytes.Length);
if (result > 0)
{
target = Encoding.UTF8.GetString(bytes);
}
}
}
return !string.IsNullOrEmpty(target);
}

private const uint GenericWrite = 0x40000000u;
private const uint BackupSemantics = 0x02000000u;
private const uint OpenReparsePoint = 0x00200000u;
private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4u;
private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003u;
private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8u;

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice,
uint IoControlCode,
ref ReparseDataBuffer InBuffer,
int nInBufferSize,
byte[] OutBuffer,
int nOutBufferSize,
out int pBytesReturned,
IntPtr Overlapped);

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice,
uint IoControlCode,
byte[] InBuffer,
int nInBufferSize,
out ReparseDataBuffer OutBuffer,
int nOutBufferSize,
out int pBytesReturned,
IntPtr Overlapped);

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct ReparseDataBuffer
{
public uint ReparseTag;
public ushort ReparseDataLength;
private readonly ushort Reserved;
public ushort SubstituteNameOffset;
public ushort SubstituteNameLength;
public ushort PrintNameOffset;
public ushort PrintNameLength;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 8184)]
public string PathBuffer;

public static ReparseDataBuffer FromPath(string target, out int byteCount)
{
var fullTarget = $@"\??\{Path.GetFullPath(target)}";
byteCount = Encoding.Unicode.GetByteCount(fullTarget);
return new ReparseDataBuffer
{
ReparseTag = IO_REPARSE_TAG_MOUNT_POINT,
ReparseDataLength = (ushort)(byteCount + 12),
SubstituteNameOffset = 0,
SubstituteNameLength = (ushort)byteCount,
PrintNameOffset = (ushort)(byteCount + 2),
PrintNameLength = 0,
PathBuffer = fullTarget,
};
}
}

[DllImport("libc")]
private static extern int readlink(string link, byte[] buf, int bufsize);

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern SafeFileHandle CreateFile([MarshalAs(UnmanagedType.LPTStr)] string filename,
[MarshalAs(UnmanagedType.U4)] uint access,
[MarshalAs(UnmanagedType.U4)] FileShare share,
IntPtr securityAttributes,
[MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
[MarshalAs(UnmanagedType.U4)] uint flagsAndAttributes,
IntPtr templateFile);

private static string TrimStart(this string orig, string toRemove)
=> orig.StartsWith(toRemove) ? orig.Remove(0, toRemove.Length)
: orig;

}
}
13 changes: 9 additions & 4 deletions Core/GameInstanceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ public GameInstance AddInstance(string path, string name, IUser user)
/// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
/// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
/// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
public void CloneInstance(GameInstance existingInstance, string newName, string newPath)
public void CloneInstance(GameInstance existingInstance,
string newName,
string newPath,
bool shareStockFolders = false)
{
if (HasInstance(newName))
{
Expand All @@ -252,11 +255,13 @@ public void CloneInstance(GameInstance existingInstance, string newName, string
}

log.Debug("Copying directory.");
Utilities.CopyDirectory(existingInstance.GameDir(), newPath, true);
Utilities.CopyDirectory(existingInstance.GameDir(), newPath,
shareStockFolders ? existingInstance.game.StockFolders
: Array.Empty<string>(),
existingInstance.game.LeaveEmptyInClones);

// Add the new instance to the config
GameInstance new_instance = new GameInstance(existingInstance.game, newPath, newName, User);
AddInstance(new_instance);
AddInstance(new GameInstance(existingInstance.game, newPath, newName, User));
}

/// <summary>
Expand Down
9 changes: 5 additions & 4 deletions Core/Games/IGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ public interface IGame
string PrimaryModDirectoryRelative { get; }
string[] AlternateModDirectoriesRelative { get; }
string PrimaryModDirectory(GameInstance inst);
string[] StockFolders { get; }
string[] ReservedPaths { get; }
string[] CreateableDirs { get; }
string[] AutoRemovableDirs { get; }
string[] StockFolders { get; }
string[] LeaveEmptyInClones { get; }
string[] ReservedPaths { get; }
string[] CreateableDirs { get; }
string[] AutoRemovableDirs { get; }
bool IsReservedDirectory(GameInstance inst, string path);
bool AllowInstallationIn(string name, out string path);
void RebuildSubdirectories(string absGameRoot);
Expand Down
11 changes: 11 additions & 0 deletions Core/Games/KerbalSpaceProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};

public string[] LeaveEmptyInClones => new string[]
{
"saves",
"Screenshots",
"thumbs",
"Missions",
"Logs",
"CKAN/history",
"CKAN/downloads",
};

public string[] ReservedPaths => new string[]
{
"GameData", "Ships", "Missions"
Expand Down
6 changes: 6 additions & 0 deletions Core/Games/KerbalSpaceProgram2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};

public string[] LeaveEmptyInClones => new string[]
{
"CKAN/history",
"CKAN/downloads",
};

public string[] ReservedPaths => Array.Empty<string>();

public string[] CreateableDirs => new string[]
Expand Down
Loading