Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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 @@ -231,6 +232,7 @@ Params
params
parentidx
pathpart
pathing
Pathto
PBYTE
pch
Expand Down
9 changes: 9 additions & 0 deletions doc/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Added a new `--ignore-unavailable` flag to the `install` command. When installin

## Bug Fixes

### 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.

### Minor Bug Fixes
* Fixed an issue where `winget search --id <msstoreId>` could fail to return a Microsoft Store package unless `--exact` was also provided.
* Updated NUnit to v4
* Fixed a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations.
92 changes: 80 additions & 12 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,20 +138,49 @@ 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) };

if (BinariesDependOnPath && !InstallDirectoryAddedToPath)
if (BinariesDependOnPath)
{
// Scenario indicated by 'ArchiveBinariesDependOnPath' manifest entry.
// Skip symlink creation for portables dependent on binaries that require the install directory to be added to PATH.
std::filesystem::path installDirectory = symlinkTargetPath.parent_path();
AddToPathVariable(installDirectory);
// Only remove the links directory if there are no links in it
RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true);
AICLI_LOG(Core, Info, << "Install directory added to PATH: " << installDirectory);
CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = true);
}
else if (!InstallDirectoryAddedToPath)
else
{
std::filesystem::file_status status = std::filesystem::status(filePath);
if (std::filesystem::is_directory(status))
Expand All @@ -157,13 +202,21 @@ namespace AppInstaller::CLI::Portable

if (Filesystem::CreateSymlink(symlinkTargetPath, filePath))
{
if (InstallDirectoryAddedToPath)
{
// If the install directory was previously added to PATH, remove it now that the symlink has been created.
RemoveFromPathVariable(symlinkTargetPath.parent_path(), false);
}
CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = false);
AICLI_LOG(Core, Info, << "Symlink created at: " << filePath << " with target path: " << symlinkTargetPath);
}
else
{
// If symlink creation fails, resort to adding the package directory to PATH.
AICLI_LOG(Core, Info, << "Failed to create symlink at: " << filePath);
AddToPathVariable(symlinkTargetPath.parent_path());
// Only remove the links directory if there are no links in it
RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true);
CommitToARPEntry(PortableValueName::InstallDirectoryAddedToPath, InstallDirectoryAddedToPath = true);
}
}
Expand All @@ -176,9 +229,9 @@ namespace AppInstaller::CLI::Portable
const auto& filePath = entry.GetFilePath();
PortableFileType fileType = entry.FileType;

if (fileType == PortableFileType::File && std::filesystem::exists(filePath))
if ((fileType == PortableFileType::File || fileType == PortableFileType::Hardlink) && std::filesystem::exists(filePath))
{
AICLI_LOG(CLI, Info, << "Deleting portable exe at: " << filePath);
AICLI_LOG(CLI, Info, << "Deleting portable file at: " << filePath);
std::filesystem::remove(filePath);
}
else if (fileType == PortableFileType::Symlink)
Expand All @@ -191,7 +244,7 @@ namespace AppInstaller::CLI::Portable
else if (InstallDirectoryAddedToPath)
{
// If symlink doesn't exist, check if install directory was added to PATH directly and remove.
RemoveFromPathVariable(std::filesystem::path(Utility::ConvertToUTF16(entry.SymlinkTarget)).parent_path());
RemoveFromPathVariable(std::filesystem::path(Utility::ConvertToUTF16(entry.SymlinkTarget)).parent_path(), false);
}
}
else if (fileType == PortableFileType::Symlink && Filesystem::SymlinkExists(filePath))
Expand Down Expand Up @@ -347,7 +400,12 @@ namespace AppInstaller::CLI::Portable

if (!InstallDirectoryAddedToPath)
{
RemoveFromPathVariable(GetPortableLinksLocation(GetScope()));
// Only remove the links directory if there are no links in it
RemoveFromPathVariable(GetPortableLinksLocation(GetScope()), true);
}
else
{
RemoveFromPathVariable(InstallLocation, false);
}

m_portableARPEntry.Delete();
Expand Down Expand Up @@ -407,15 +465,15 @@ namespace AppInstaller::CLI::Portable
}
}

void PortableInstaller::RemoveFromPathVariable(std::filesystem::path value)
void PortableInstaller::RemoveFromPathVariable(std::filesystem::path value, bool onlyIfEmpty)
{
if (std::filesystem::exists(value) && !std::filesystem::is_empty(value))
if (onlyIfEmpty && std::filesystem::exists(value) && !std::filesystem::is_empty(value))
{
AICLI_LOG(Core, Info, << "Install directory is not empty: " << value);
AICLI_LOG(Core, Info, << "Directory is not empty: " << value);
}
else
{
// Attempt to remove both the original and the preferred format to ensure removal
// Attempt to remove both the original and the preferred format to ensure removal
// Necessary for handling old path values associated with winget-cli#5033
if (PathVariable(GetScope()).Remove(value) || PathVariable(GetScope()).Remove(value.make_preferred()))
{
Expand Down Expand Up @@ -489,12 +547,22 @@ namespace AppInstaller::CLI::Portable
// This is to ensure that the directory is fully uninstalled before attempting to remove from PATH registry.
if (!targetFullPath.empty())
{
m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateFileEntry({}, targetFullPath, SHA256)));
m_expectedEntries.emplace_back(PortableFileEntry::CreateFileEntry({}, targetFullPath, SHA256));
}

if (!symlinkFullPath.empty())
{
m_expectedEntries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkFullPath, targetFullPath)));
// 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(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, SHA256));
}
}
m_expectedEntries.emplace_back(PortableFileEntry::CreateSymlinkEntry(symlinkFullPath, targetFullPath));
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/AppInstallerCLICore/PortableInstaller.h
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,6 @@ namespace AppInstaller::CLI::Portable
void RemoveInstallDirectory();

void AddToPathVariable(std::filesystem::path value);
void RemoveFromPathVariable(std::filesystem::path value);
void RemoveFromPathVariable(std::filesystem::path value, bool onlyIfEmpty);
};
}
55 changes: 44 additions & 11 deletions src/AppInstallerCLICore/Workflows/PortableFlow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "PortableFlow.h"
#include "PortableInstaller.h"
#include "WorkflowBase.h"
#include <map>
#include <winget/Filesystem.h>
#include <winget/PortableFileEntry.h>
#include <winget/PortableIndex.h>
Expand Down Expand Up @@ -185,49 +186,69 @@ namespace AppInstaller::CLI::Workflow
{
portableInstaller.RecordToIndex = true;

// Build a map of installed target path → SHA256 as file entries are created,
// so nested installer files that need hardlinks can reuse the hash without re-reading from disk.
std::map<std::filesystem::path, std::string> fileHashes;

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;

if (std::filesystem::is_directory(entryPath))
{
entries.emplace_back(std::move(PortableFileEntry::CreateDirectoryEntry(entryPath, targetPath)));
entries.emplace_back(PortableFileEntry::CreateDirectoryEntry(entryPath, targetPath));
}
else
{
entries.emplace_back(std::move(PortableFileEntry::CreateFileEntry(entryPath, targetPath, {})));
const PortableFileEntry& fileEntry = entries.emplace_back(PortableFileEntry::CreateFileEntry(entryPath, targetPath, {}));
fileHashes[std::filesystem::weakly_canonical(targetPath)] = fileEntry.SHA256;
}
}

const std::vector<Manifest::NestedInstallerFile>& nestedInstallerFiles = context.Get<Execution::Data::Installer>()->NestedInstallerFiles;

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())
{
commandAlias = targetPath.filename();
commandAlias = originalFilename;
}
else
{
commandAlias = ConvertToUTF16(nestedInstallerFile.PortableCommandAlias);
}

Filesystem::AppendExtension(commandAlias, ".exe");
entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetPath)));

// 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 hardlinkPath = targetPath.parent_path() / commandAlias;
auto it = fileHashes.find(std::filesystem::weakly_canonical(targetPath));
THROW_HR_IF_MSG(APPINSTALLER_CLI_ERROR_PORTABLE_INSTALL_FAILED, it == fileHashes.end(), "Hash not found for hardlink target: %ls", targetPath.c_str());
entries.emplace_back(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetPath, it->second));
}
entries.emplace_back(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,11 +258,23 @@ 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, {})));
entries.emplace_back(std::move(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, 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(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(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetFullPath, fileSha256));
}
entries.emplace_back(PortableFileEntry::CreateSymlinkEntry(symlinkDirectory / commandAlias, targetFullPath));
}

return entries;
Expand Down
16 changes: 16 additions & 0 deletions src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,22 @@ public static void VerifyPortablePackage(
Assert.That(isAddedToPath, Is.EqualTo(shouldExist), $"Expected path variable: {(installDirectoryAddedToPath ? installDir : symlinkDirectory)}{pathDiagnostics}");
}

/// <summary>
/// Check if a path value exists in PATH.
/// </summary>
/// <param name="value">Path value.</param>
/// <param name="scope">Scope.</param>
/// <returns>True if PATH contains the value.</returns>
public static bool PathContainsValue(string value, Scope scope = Scope.User)
{
RegistryKey baseKey = scope == Scope.User ? Registry.CurrentUser : Registry.LocalMachine;
string pathSubKey = scope == Scope.User ? Constants.PathSubKey_User : Constants.PathSubKey_Machine;
using RegistryKey environmentRegistryKey = baseKey.OpenSubKey(pathSubKey, true);
string currentPathValue = (string)environmentRegistryKey.GetValue("Path") ?? string.Empty;
string expectedValue = value.TrimEnd('\\') + ';';
return currentPathValue.Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Copies log files to the path %TEMP%\E2ETestLogs.
/// </summary>
Expand Down
Loading
Loading