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 @@
HCRYPTMSG
HGlobal
HGLOBAL
hardlinks
HIDECANCEL
hinternet
HKCU
Expand Down Expand Up @@ -230,6 +231,7 @@
params
parentidx
pathpart
pathing
Pathto
PBYTE
pch
Expand Down Expand Up @@ -369,8 +371,8 @@
UAC
UACONLY
uap
UBool

Check warning on line 374 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Entry has inconsistent line endings (unexpected-line-ending)
UBreak

Check warning on line 375 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Entry has inconsistent line endings (unexpected-line-ending)
ubrk
ucol
UCollation
Expand Down Expand Up @@ -440,3 +442,3 @@
XResource
XTOKEN
xunit
7 changes: 7 additions & 0 deletions doc/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@ Nothing yet.

## 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 a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations.
89 changes: 81 additions & 8 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 @@ -181,6 +234,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 All @@ -191,7 +249,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 @@ -315,7 +373,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 @@ -375,15 +438,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 @@ -462,6 +525,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
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);
};
}
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
17 changes: 17 additions & 0 deletions src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,23 @@ public static void VerifyPortablePackage(
Assert.AreEqual(shouldExist, isAddedToPath, $"Expected path variable: {(installDirectoryAddedToPath ? installDir : symlinkDirectory)}");
}

/// <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