Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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))
Comment thread
Trenly marked this conversation as resolved.
Outdated
{
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));
Comment thread
Trenly marked this conversation as resolved.
Outdated
entries.emplace_back(std::move(PortableFileEntry::CreateHardlinkEntry(hardlinkPath, targetPath, sha256)));
Comment thread
Trenly marked this conversation as resolved.
Outdated
}
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