Skip to content

Commit 1d6958c

Browse files
committed
PackageManager: Add checks on write rights
To be able to install a package, the package manager calls pip, pip needs to have write permissions otherwise it will fail. CloudCompare on windows being generally installed in C:\Programs that requires admin rights, so a non admin cannot pip install things in there. There have been many user confused by this, so its easier to block installation and have a message if we detect the folder is not writable Also, the package manager should now automatically use the `--user` option for pip install when it's using the system's Python
1 parent d446085 commit 1d6958c

4 files changed

Lines changed: 223 additions & 12 deletions

File tree

src/PackageManager.cpp

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,128 @@
3434

3535
#include <ui_InstallDialog.h>
3636

37+
#include "Utilities.h"
38+
39+
#if defined(Q_OS_WIN32)
40+
#include <Windows.h>
41+
#endif
42+
43+
#if defined(Q_OS_WIN32)
44+
static BOOL GetFolderRights(LPCTSTR folderName, DWORD genericAccessRights, DWORD *grantedRights)
45+
{
46+
DWORD length = 0;
47+
48+
constexpr SECURITY_INFORMATION requestedInformation =
49+
OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION;
50+
51+
/* Get the needed length */
52+
GetFileSecurity(folderName, requestedInformation, nullptr, NULL, &length);
53+
54+
if (ERROR_INSUFFICIENT_BUFFER != GetLastError())
55+
{
56+
return FALSE;
57+
}
58+
59+
PSECURITY_DESCRIPTOR security = LocalAlloc(LPTR, length);
60+
if (GetFileSecurity(folderName, requestedInformation, security, length, &length) == FALSE)
61+
{
62+
LocalFree(security);
63+
return FALSE;
64+
}
65+
66+
DWORD desiredAccess = TOKEN_IMPERSONATE | TOKEN_QUERY | TOKEN_DUPLICATE | STANDARD_RIGHTS_READ;
67+
HANDLE hToken = nullptr;
68+
if (OpenProcessToken(GetCurrentProcess(), desiredAccess, &hToken) == FALSE)
69+
{
70+
LocalFree(security);
71+
return FALSE;
72+
}
73+
74+
HANDLE hImpersonatedToken = nullptr;
75+
if (DuplicateToken(hToken, SecurityImpersonation, &hImpersonatedToken) == FALSE)
76+
{
77+
CloseHandle(hToken);
78+
LocalFree(security);
79+
return FALSE;
80+
}
81+
82+
GENERIC_MAPPING mapping = {0xFFFFFFFF};
83+
PRIVILEGE_SET privileges = {0};
84+
DWORD grantedAccess = 0, privilegesLength = sizeof(privileges);
85+
BOOL result = FALSE;
86+
87+
mapping.GenericRead = FILE_GENERIC_READ;
88+
mapping.GenericWrite = FILE_GENERIC_WRITE;
89+
mapping.GenericExecute = FILE_GENERIC_EXECUTE;
90+
mapping.GenericAll = FILE_ALL_ACCESS;
91+
92+
MapGenericMask(&genericAccessRights, &mapping);
93+
if (AccessCheck(security,
94+
hImpersonatedToken,
95+
genericAccessRights,
96+
&mapping,
97+
&privileges,
98+
&privilegesLength,
99+
&grantedAccess,
100+
&result) == FALSE)
101+
{
102+
CloseHandle(hImpersonatedToken);
103+
CloseHandle(hToken);
104+
LocalFree(security);
105+
return FALSE;
106+
}
107+
108+
*grantedRights = grantedAccess;
109+
110+
CloseHandle(hImpersonatedToken);
111+
CloseHandle(hToken);
112+
LocalFree(security);
113+
114+
return TRUE;
115+
}
116+
117+
static bool HasReadWriteAccessToFolder(const QString &folderPath)
118+
{
119+
constexpr DWORD access_mask = MAXIMUM_ALLOWED;
120+
DWORD grant = 0;
121+
#if defined(UNICODE)
122+
const std::wstring str = folderPath.toStdWString();
123+
BOOL ret = GetFolderRights(str.c_str(), access_mask, &grant);
124+
#else
125+
const std::string str = folderPath.toStdString();
126+
BOOL ret = GetFolderRights(str.c_str(), access_mask, &grant);
127+
#endif
128+
129+
if (ret == FALSE)
130+
{
131+
plgWarning() << "Failed to get access rights for path '" << folderPath << '\n';
132+
return false;
133+
}
134+
135+
bool hasRead = false;
136+
if (((grant & GENERIC_READ) == GENERIC_READ) ||
137+
((grant & FILE_GENERIC_READ) == FILE_GENERIC_READ))
138+
{
139+
hasRead = true;
140+
}
141+
142+
bool hasWrite = false;
143+
if (((grant & GENERIC_WRITE) == GENERIC_WRITE) ||
144+
((grant & FILE_GENERIC_WRITE) == FILE_GENERIC_WRITE))
145+
{
146+
hasWrite = true;
147+
}
148+
149+
bool hasExecute = false;
150+
if (((grant & GENERIC_EXECUTE) == GENERIC_EXECUTE) ||
151+
((grant & FILE_GENERIC_EXECUTE) == FILE_GENERIC_EXECUTE))
152+
{
153+
hasExecute = true;
154+
}
155+
return hasRead && hasWrite && hasExecute;
156+
}
157+
#endif // defined(Q_OS_WIN32)
158+
37159
class InstallDialog : public QDialog
38160
{
39161
Q_OBJECT
@@ -75,12 +197,12 @@ class CommandOutputDialog : public QDialog
75197
resize(600, 300);
76198
}
77199

78-
void appendPlainText(const QString &text)
200+
void appendPlainText(const QString &text) const
79201
{
80202
m_display->appendPlainText(text);
81203
}
82204

83-
void clear()
205+
void clear() const
84206
{
85207
m_display->clear();
86208
}
@@ -93,7 +215,8 @@ PackageManager::PackageManager(const PythonConfig &config, QWidget *parent)
93215
: QWidget(parent),
94216
m_ui(new Ui_PackageManager),
95217
m_pythonProcess(new QProcess),
96-
m_outputDialog(new CommandOutputDialog(this))
218+
m_outputDialog(new CommandOutputDialog(this)),
219+
m_shouldUseUserOption(false)
97220
{
98221
m_ui->setupUi(this);
99222
connect(m_pythonProcess, &QProcess::started, [this]() { setBusy(true); });
@@ -120,6 +243,45 @@ PackageManager::PackageManager(const PythonConfig &config, QWidget *parent)
120243
connect(m_ui->searchBar, &QLineEdit::returnPressed, this, &PackageManager::handleSearch);
121244
config.preparePythonProcess(*m_pythonProcess);
122245
refreshInstalledPackagesList();
246+
247+
m_ui->installBtn->setEnabled(true);
248+
m_ui->messageFrame->hide();
249+
250+
switch (config.type())
251+
{
252+
// The main intent of checking access rights of venv is for the Windows bundled
253+
// env, but checking for all venv won't hurt.
254+
// on Windowsn the bundled env is very likely to be installed in
255+
// "C:\Programs\Cloud Compare\plugins\Python" and that requires admin rights to add/modify.
256+
// It is better to notify user and prevent them from trying something that will faill.
257+
case PythonConfig::Type::Venv:
258+
case PythonConfig::Type::Conda:
259+
case PythonConfig::Type::Unknown:
260+
{
261+
#if defined Q_OS_WIN32
262+
// On Windows we use a custom function because isWritable from Qt was not correct
263+
// for our use case (its mentionned in their doc)
264+
const bool hasEnoughRights = HasReadWriteAccessToFolder(config.pythonHome());
265+
#else
266+
const QFileInfo dirInfo(config.pythonHome());
267+
const bool hasEnoughRights = dirInfo.isWritable();
268+
#endif
269+
if (!hasEnoughRights)
270+
{
271+
m_ui->installBtn->setEnabled(false);
272+
m_ui->messageIconLabel->setPixmap(QApplication::style()
273+
->standardIcon(QStyle::SP_MessageBoxCritical)
274+
.pixmap({64, 64}));
275+
m_ui->messageTextLabel->setText("Admin rights are required to be able to install "
276+
"packages in the current environment");
277+
m_ui->messageFrame->show();
278+
}
279+
}
280+
break;
281+
case PythonConfig::Type::System:
282+
m_shouldUseUserOption = true;
283+
break;
284+
}
123285
}
124286

125287
void PackageManager::refreshInstalledPackagesList()
@@ -225,6 +387,10 @@ void PackageManager::handleInstallPackage()
225387
{
226388
arguments.push_back("--upgrade");
227389
}
390+
if (m_shouldUseUserOption)
391+
{
392+
arguments.push_back("--user");
393+
}
228394
executeCommand(arguments);
229395

230396
if (m_pythonProcess->exitCode() != 0)

src/PackageManager.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class PackageManager final : public QWidget
5151
Ui_PackageManager *m_ui;
5252
QProcess *m_pythonProcess;
5353
CommandOutputDialog *m_outputDialog;
54+
bool m_shouldUseUserOption;
5455
};
5556

5657
#endif // PYTHON_PLUGIN_PACKAGE_MANAGER_H

src/PythonConfig.h

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,6 @@ class PythonConfig final
8787
Unknown
8888
};
8989

90-
/// # On Windows:
91-
/// Initialize python home and python path
92-
/// corresponding to the environment to be used.
93-
///
94-
/// # Other Platforms
95-
/// Does nothing, as we rely on the system's python to be properly installed
9690
PythonConfig() = default;
9791

9892
Type type() const
@@ -120,6 +114,11 @@ class PythonConfig final
120114
return o;
121115
}
122116

117+
const QString &pythonHome() const
118+
{
119+
return m_pythonHome;
120+
}
121+
123122
/// Sets the necessary settings of the QProcess so that
124123
/// it uses the correct Python exe.
125124
void preparePythonProcess(QProcess &pythonProcess) const;
@@ -148,6 +147,12 @@ class PythonConfig final
148147
static bool IsInsideEnvironment();
149148
static PythonConfig fromContainingEnvironment();
150149

150+
/// # On Windows:
151+
/// Initialize python home and python path
152+
/// corresponding to the environment to be used.
153+
///
154+
/// # Other Platforms
155+
/// Does nothing, as we rely on the system's python to be properly installed
151156
void initDefault();
152157
#ifdef Q_OS_WIN32
153158
/// Initialize the paths to point to where the Python

src/Ui/PackageManager.ui

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,55 @@
66
<rect>
77
<x>0</x>
88
<y>0</y>
9-
<width>660</width>
10-
<height>425</height>
9+
<width>694</width>
10+
<height>495</height>
1111
</rect>
1212
</property>
1313
<property name="windowTitle">
1414
<string>Packages</string>
1515
</property>
16-
<layout class="QHBoxLayout" name="horizontalLayout">
16+
<layout class="QHBoxLayout" name="horizontalLayout_2">
1717
<item>
1818
<layout class="QVBoxLayout" name="verticalLayout_2">
19+
<item>
20+
<widget class="QFrame" name="messageFrame">
21+
<property name="frameShape">
22+
<enum>QFrame::Box</enum>
23+
</property>
24+
<property name="frameShadow">
25+
<enum>QFrame::Raised</enum>
26+
</property>
27+
<layout class="QHBoxLayout" name="horizontalLayout">
28+
<item>
29+
<widget class="QLabel" name="messageIconLabel">
30+
<property name="text">
31+
<string>TextLabel</string>
32+
</property>
33+
</widget>
34+
</item>
35+
<item>
36+
<widget class="QLabel" name="messageTextLabel">
37+
<property name="text">
38+
<string>TextLabel</string>
39+
</property>
40+
</widget>
41+
</item>
42+
<item>
43+
<spacer name="messageSpacer">
44+
<property name="orientation">
45+
<enum>Qt::Horizontal</enum>
46+
</property>
47+
<property name="sizeHint" stdset="0">
48+
<size>
49+
<width>456</width>
50+
<height>20</height>
51+
</size>
52+
</property>
53+
</spacer>
54+
</item>
55+
</layout>
56+
</widget>
57+
</item>
1958
<item>
2059
<widget class="QLineEdit" name="searchBar"/>
2160
</item>

0 commit comments

Comments
 (0)