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
2 changes: 2 additions & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ hcertstore
HCRYPTMSG
HGlobal
HGLOBAL
hardlinks
HIDECANCEL
hinternet
HKCU
Expand Down Expand Up @@ -228,6 +229,7 @@ Params
params
parentidx
pathpart
pathing
Pathto
PBYTE
pch
Expand Down
6 changes: 5 additions & 1 deletion doc/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ Nothing yet.

## Bug Fixes

* None yet
### Portable installer alias handling

Portable installs now preserve the original executable filename instead of renaming it when an alias is needed. For aliases requested through `--rename`, `Commands`, or `PortableCommandAlias`, WinGet creates a hardlink alias and keeps the original file as the source executable.

This change resolves alias failures in non-symlinked scenarios, including cases where WinGet adds the install directory to `PATH` instead of creating links. Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped.
58 changes: 58 additions & 0 deletions src/AppInstallerCLICore/PortableInstaller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ namespace AppInstaller::CLI::Portable
}
}
}
else if (fileType == PortableFileType::Hardlink)
{
if (std::filesystem::exists(filePath))
{
// Only verify hash if one was stored
if (!entry.SHA256.empty())
{
SHA256::HashBuffer fileHash = SHA256::ComputeHashFromFile(filePath);
if (!SHA256::AreEqual(fileHash, SHA256::ConvertToBytes(entry.SHA256)))
{
AICLI_LOG(CLI, Warning, << "Hardlink hash does not match ARP Entry. Expected: " << entry.SHA256 << " Actual: " << SHA256::ConvertToString(fileHash));
return false;
}
}
}
}
else if (fileType == PortableFileType::Symlink)
{
std::filesystem::path symlinkTargetPath{ AppInstaller::Utility::ConvertToUTF16(entry.SymlinkTarget) };
Expand Down Expand Up @@ -122,6 +138,33 @@ namespace AppInstaller::CLI::Portable
std::filesystem::copy(entry.CurrentPath, filePath, std::filesystem::copy_options::overwrite_existing | std::filesystem::copy_options::recursive);
}
}
else if (fileType == PortableFileType::Hardlink)
{
if (std::filesystem::exists(filePath))
{
AICLI_LOG(Core, Info, << "Removing existing portable hardlink at: " << filePath);
std::filesystem::remove(filePath);
}

AICLI_LOG(Core, Info, << "Creating hardlink at: " << filePath << " pointing to: " << entry.CurrentPath);

// Try to create hardlink
if (Filesystem::CreateHardlink(entry.CurrentPath, filePath))
{
AICLI_LOG(Core, Info, << "Hardlink created successfully at: " << filePath);
}
else
{
// Fallback: copy the file if hardlinks not supported
AICLI_LOG(Core, Info, << "Hardlink creation failed, falling back to copy: " << filePath);
std::filesystem::copy_file(entry.CurrentPath, filePath, std::filesystem::copy_options::overwrite_existing);
}

if (!RecordToIndex)
{
CommitToARPEntry(PortableValueName::SHA256, entry.SHA256);
}
}
else if (entry.FileType == PortableFileType::Symlink)
{
std::filesystem::path symlinkTargetPath{ Utility::ConvertToUTF16(entry.SymlinkTarget) };
Expand Down Expand Up @@ -181,6 +224,11 @@ namespace AppInstaller::CLI::Portable
AICLI_LOG(CLI, Info, << "Deleting portable exe at: " << filePath);
std::filesystem::remove(filePath);
}
else if (fileType == PortableFileType::Hardlink && std::filesystem::exists(filePath))
{
AICLI_LOG(CLI, Info, << "Deleting portable hardlink at: " << filePath);
std::filesystem::remove(filePath);
}
else if (fileType == PortableFileType::Symlink)
{
if (Filesystem::SymlinkExists(filePath))
Expand Down Expand Up @@ -462,6 +510,16 @@ namespace AppInstaller::CLI::Portable

if (!symlinkFullPath.empty())
{
// If alias differs from original filename, a hardlink exists in the install directory.
// Track it so uninstall removes it even when state is reconstructed from ARP values.
if (!targetFullPath.empty() && targetFullPath.filename() != symlinkFullPath.filename())
{
std::filesystem::path hardlinkPath = InstallLocation / symlinkFullPath.filename();
if (hardlinkPath != targetFullPath && std::filesystem::exists(hardlinkPath))
{
m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, SHA256)));
}
}
m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkFullPath, targetFullPath)));
}
}
Expand Down
40 changes: 34 additions & 6 deletions src/AppInstallerCLICore/Workflows/PortableFlow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ namespace AppInstaller::CLI::Workflow
for (const auto& entry : std::filesystem::directory_iterator(installerPath))
{
std::filesystem::path entryPath = entry.path();
PortableFileEntry portableFile;
std::filesystem::path relativePath = std::filesystem::relative(entryPath, entryPath.parent_path());
std::filesystem::path targetPath = targetInstallDirectory / relativePath;

Expand All @@ -206,7 +205,9 @@ namespace AppInstaller::CLI::Workflow

for (const auto& nestedInstallerFile : nestedInstallerFiles)
{
const std::filesystem::path& targetPath = targetInstallDirectory / ConvertToUTF16(nestedInstallerFile.RelativeFilePath);
const std::filesystem::path& relativeFilePath = ConvertToUTF16(nestedInstallerFile.RelativeFilePath);
const std::filesystem::path& targetPath = targetInstallDirectory / relativeFilePath;
std::filesystem::path originalFilename = targetPath.filename();

std::filesystem::path commandAlias;
if (nestedInstallerFile.PortableCommandAlias.empty())
Expand All @@ -219,15 +220,30 @@ namespace AppInstaller::CLI::Workflow
}

Filesystem::AppendExtension(commandAlias, ".exe");

// If alias differs from original filename, create hardlink
// Hardlink will be placed in the same directory as the original file to avoid pathing issues and same-volume restrictions
if (commandAlias != originalFilename)
{
std::filesystem::path sourcePath = installerPath / relativeFilePath;
std::filesystem::path hardlinkPath = targetPath.parent_path() / commandAlias;
// Compute SHA256 from source file for the hardlink entry
std::string sha256 = Utility::SHA256::ConvertToString(Utility::SHA256::ComputeHashFromFile(sourcePath));
entries.emplace_back(std::move(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetPath, sha256)));
}
entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetPath)));
}
}
else
{
// Non-archive portable case: single executable file
std::string_view renameArg = context.Args.GetArg(Execution::Args::Type::Rename);
const std::vector<string_t>& commands = context.Get<Execution::Data::Installer>()->Commands;
std::filesystem::path commandAlias = installerPath.filename();

std::filesystem::path originalFilename = installerPath.filename();
std::filesystem::path commandAlias = originalFilename;

// Determine the command alias from rename arg, commands, or use original filename
if (!commands.empty())
{
commandAlias = ConvertToUTF16(commands[0]);
Expand All @@ -237,10 +253,22 @@ namespace AppInstaller::CLI::Workflow
{
commandAlias = ConvertToUTF16(renameArg);
}
AppInstaller::Filesystem::AppendExtension(commandAlias, ".exe");

const std::filesystem::path& targetFullPath = targetInstallDirectory / commandAlias;
entries.emplace_back(std::move(PortableFileEntry::CreateFileEntry(installerPath, targetFullPath, {})));
Filesystem::AppendExtension(commandAlias, ".exe");

// Target path for the original file (keeps its original name)
const std::filesystem::path& targetFullPath = targetInstallDirectory / originalFilename;

// Create file entry for original (with original name) - this computes SHA256
std::string fileSha256 = Utility::SHA256::ConvertToString(Utility::SHA256::ComputeHashFromFile(installerPath));
entries.emplace_back(std::move(PortableFileEntry::CreateFileEntry(installerPath, targetFullPath, fileSha256)));

// If alias differs from original filename, create hardlink
if (commandAlias != originalFilename)
{
std::filesystem::path hardlinkPath = targetInstallDirectory / commandAlias;
entries.emplace_back(std::move(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, fileSha256)));
}
entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetFullPath)));
}

Expand Down
112 changes: 112 additions & 0 deletions src/AppInstallerCLIE2ETests/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,118 @@ public void InstallZip_ArchivePortableWithBinariesDependentOnPath()
TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, true, TestCommon.Scope.User, true);
}

/// <summary>
/// Test install portable with rename creates hardlink instead of renaming original.
/// </summary>
[Test]
public void InstallPortableWithRename_VerifyHardlink()
{
string installDir = TestCommon.GetPortablePackagesDirectory();
string packageId, packageDirName, productCode;
packageId = "AppInstallerTest.TestPortableExeWithCommand";
packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier;
string renameArgValue = "customAlias.exe";

var result = TestCommon.RunAICLICommand("install", $"{packageId} --rename {renameArgValue}");
Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode);
Assert.True(result.StdOut.Contains("Successfully installed"));

string installPath = Path.Combine(installDir, packageDirName);
string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe");
string hardlinkFile = Path.Combine(installPath, renameArgValue);

// Verify original file exists with original name (not renamed)
Assert.True(File.Exists(originalFile), $"Original file should exist at: {originalFile}");

// Verify hardlink exists
Assert.True(File.Exists(hardlinkFile), $"Hardlink should exist at: {hardlinkFile}");

// Verify hardlink and original point to same content (equivalence)
byte[] originalBytes = File.ReadAllBytes(originalFile);
byte[] hardlinkBytes = File.ReadAllBytes(hardlinkFile);
Assert.AreEqual(originalBytes.Length, hardlinkBytes.Length, "File sizes should be equal");
Assert.True(originalBytes.AsSpan().SequenceEqual(hardlinkBytes.AsSpan()), "Hardlink should be equivalent to original file");

// Verify uninstall removes both original and hardlink
var uninstallResult = TestCommon.RunAICLICommand("uninstall", $"{packageId}");
Assert.AreEqual(Constants.ErrorCode.S_OK, uninstallResult.ExitCode);
Assert.False(File.Exists(originalFile), $"Original file should be removed after uninstall");
Assert.False(File.Exists(hardlinkFile), $"Hardlink should be removed after uninstall");
}

/// <summary>
/// Test install portable with Commands field creates hardlinks for all command aliases.
/// </summary>
[Test]
public void InstallPortableWithCommands_VerifyHardlinks()
{
string installDir = TestCommon.GetPortablePackagesDirectory();
string packageId, packageDirName, productCode;
packageId = "AppInstallerTest.TestPortableExeWithCommand";
packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier;
string commandAlias = "testCommand.exe";

var result = TestCommon.RunAICLICommand("install", $"{packageId}");
Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode);
Assert.True(result.StdOut.Contains("Successfully installed"));

string installPath = Path.Combine(installDir, packageDirName);
string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe");
string hardlinkFile = Path.Combine(installPath, commandAlias);

// Verify original file exists with original name
Assert.True(File.Exists(originalFile), $"Original file should exist at: {originalFile}");

// Verify command alias hardlink exists
Assert.True(File.Exists(hardlinkFile), $"Command alias hardlink should exist at: {hardlinkFile}");

// Verify hardlink is equivalent to original
byte[] originalBytes = File.ReadAllBytes(originalFile);
byte[] hardlinkBytes = File.ReadAllBytes(hardlinkFile);
Assert.AreEqual(originalBytes.Length, hardlinkBytes.Length, "File sizes should be equal");
Assert.True(originalBytes.AsSpan().SequenceEqual(hardlinkBytes.AsSpan()), "Command alias hardlink should be equivalent to original file");

// Cleanup
TestCommon.RunAICLICommand("uninstall", $"{packageId}");
}

/// <summary>
/// Test install zip portable with PortableCommandAlias creates hardlinks for nested files.
/// </summary>
[Test]
public void InstallZip_PortableWithCommandAlias_VerifyHardlinks()
{
string installDir = TestCommon.GetPortablePackagesDirectory();
string packageId, packageDirName, productCode;
packageId = "AppInstallerTest.TestZipInstallerWithPortable";
packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier;
string originalFileName = "AppInstallerTestExeInstaller.exe";
string commandAlias = "TestPortable.exe";

var result = TestCommon.RunAICLICommand("install", $"{packageId}");
Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode);
Assert.True(result.StdOut.Contains("Successfully installed"));

string installPath = Path.Combine(installDir, packageDirName);
string originalFile = Path.Combine(installPath, originalFileName);
string hardlinkFile = Path.Combine(installPath, commandAlias);

// Verify original extracted file exists with original name
Assert.True(File.Exists(originalFile), $"Original extracted file should exist at: {originalFile}");

// Verify hardlink for command alias exists
Assert.True(File.Exists(hardlinkFile), $"Command alias hardlink should exist at: {hardlinkFile}");

// Verify hardlink is equivalent to original
byte[] originalBytes = File.ReadAllBytes(originalFile);
byte[] hardlinkBytes = File.ReadAllBytes(hardlinkFile);
Assert.AreEqual(originalBytes.Length, hardlinkBytes.Length, "File sizes should be equal");
Assert.True(originalBytes.AsSpan().SequenceEqual(hardlinkBytes.AsSpan()), "Archive portable hardlink should be equivalent to original file");

// Cleanup
TestCommon.RunAICLICommand("uninstall", $"{packageId}");
}

/// <summary>
/// Test install zip with invalid relative file path.
/// </summary>
Expand Down
35 changes: 35 additions & 0 deletions src/AppInstallerCLIE2ETests/UninstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,41 @@ public void UninstallZip_Portable()
TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, false);
}

/// <summary>
/// Test uninstall portable package removes hardlinks.
/// </summary>
[Test]
public void UninstallPortableWithCommands_RemovesHardlinks()
{
string installDir = TestCommon.GetPortablePackagesDirectory();
string packageId, packageDirName, productCode;
packageId = "AppInstallerTest.TestPortableExeWithCommand";
packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier;

// Install
var installResult = TestCommon.RunAICLICommand("install", $"{packageId}");
Assert.AreEqual(Constants.ErrorCode.S_OK, installResult.ExitCode);
Assert.True(installResult.StdOut.Contains("Successfully installed"));

string installPath = Path.Combine(installDir, packageDirName);
string originalFile = Path.Combine(installPath, "AppInstallerTestExeInstaller.exe");
string hardlinkFile = Path.Combine(installPath, "testCommand.exe");

// Verify files exist after install
Assert.True(File.Exists(originalFile), "Original file should exist after install");
Assert.True(File.Exists(hardlinkFile), "Hardlink should exist after install");

// Uninstall
var uninstallResult = TestCommon.RunAICLICommand("uninstall", $"{packageId}");
Assert.AreEqual(Constants.ErrorCode.S_OK, uninstallResult.ExitCode);
Assert.True(uninstallResult.StdOut.Contains("Successfully uninstalled"));

// Verify both original and hardlink are removed
Assert.False(File.Exists(originalFile), "Original file should be removed after uninstall");
Assert.False(File.Exists(hardlinkFile), "Hardlink should be removed after uninstall");
Assert.False(Directory.Exists(installPath), "Installation directory should be removed after uninstall");
}

/// <summary>
/// Test uninstall not indexed.
/// </summary>
Expand Down
14 changes: 13 additions & 1 deletion src/AppInstallerCommonCore/Public/winget/PortableFileEntry.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ namespace AppInstaller::Portable
Unknown,
File,
Directory,
Symlink
Symlink,
Hardlink
};

// Metadata representation of a portable file placed down during installation
Expand Down Expand Up @@ -66,6 +67,17 @@ namespace AppInstaller::Portable
return symlinkEntry;
}

static PortableFileEntry CreateHardlinkEntry(const std::filesystem::path& hardlinkPath, const std::filesystem::path& targetPath, const std::string& sha256 = {})
{
PortableFileEntry hardlinkEntry;
hardlinkEntry.FileType = PortableFileType::Hardlink;
hardlinkEntry.CurrentPath = targetPath;
hardlinkEntry.SetFilePath(hardlinkPath);
// Use provided SHA256 or empty (will be computed from target after install)
hardlinkEntry.SHA256 = sha256;
return hardlinkEntry;
}

static PortableFileEntry CreateDirectoryEntry(const std::filesystem::path& currentPath, const std::filesystem::path& directoryPath)
{
PortableFileEntry directoryEntry;
Expand Down
Loading
Loading