Skip to content

Commit 32f91ad

Browse files
authored
Improved error handling of long paths for EXE installers (#1228)
1 parent 83b3c35 commit 32f91ad

10 files changed

Lines changed: 219 additions & 61 deletions

File tree

CONSTRUCT.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -552,11 +552,11 @@ interactive installation. (Windows only).
552552

553553
Only applies if `register_python` is true.
554554

555-
### `check_path_length`
555+
### ~~`check_path_length`~~
556556

557-
Check the length of the path where the distribution is installed to ensure nodejs
558-
can be installed. Raise a message to request shorter paths (less than 46 character)
559-
or enable long paths on windows > 10 (require admin right). Default is False. (Windows only).
557+
_Deprecated_. Path length validation is now always performed using the computed
558+
maximum relative path length from the package contents. This option will be
559+
removed in a future version. (Windows only).
560560

561561
### `check_path_spaces`
562562

constructor/_schema.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,11 @@ class ConstructorConfiguration(BaseModel):
721721
722722
Only applies if `register_python` is true.
723723
"""
724-
check_path_length: bool = False
724+
check_path_length: bool = Field(False, deprecated=True)
725725
"""
726-
Check the length of the path where the distribution is installed to ensure nodejs
727-
can be installed. Raise a message to request shorter paths (less than 46 character)
728-
or enable long paths on windows > 10 (require admin right). Default is False. (Windows only).
726+
_Deprecated_. Path length validation is now always performed using the computed
727+
maximum relative path length from the package contents. This option will be
728+
removed in a future version. (Windows only).
729729
"""
730730
check_path_spaces: bool = True
731731
"""

constructor/data/construct.schema.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@
477477
},
478478
"check_path_length": {
479479
"default": false,
480-
"description": "Check the length of the path where the distribution is installed to ensure nodejs can be installed. Raise a message to request shorter paths (less than 46 character) or enable long paths on windows > 10 (require admin right). Default is False. (Windows only).",
480+
"deprecated": true,
481+
"description": "_Deprecated_. Path length validation is now always performed using the computed maximum relative path length from the package contents. This option will be removed in a future version. (Windows only).",
481482
"title": "Check Path Length",
482483
"type": "boolean"
483484
},

constructor/fcp.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
fcp (fetch conda packages) module
88
"""
99

10+
from __future__ import annotations
11+
1012
import logging
1113
import os
1214
import shutil
@@ -38,6 +40,9 @@
3840
)
3941

4042
if TYPE_CHECKING:
43+
from collections.abc import Iterable
44+
from typing import Literal
45+
4146
from .conda_interface import PackageCacheRecord
4247

4348
logger = logging.getLogger(__name__)
@@ -144,15 +149,47 @@ def _fetch(download_dir, precs):
144149
return list(dict.fromkeys(PrefixGraph(pc.iter_records()).graph))
145150

146151

147-
def check_duplicates_files(pc_recs, platform, duplicate_files="error"):
152+
def check_duplicates_files(
153+
pc_recs: Iterable[PackageCacheRecord],
154+
platform: str,
155+
duplicate_files: Literal["error", "warn", "skip"] = "error",
156+
env_prefixes: dict[PackageCacheRecord, str] | None = None,
157+
) -> tuple[int, int, int]:
158+
"""
159+
Check for duplicate files across packages and compute size/path metrics.
160+
161+
Iterates through all files in the provided package cache records to:
162+
1. Detect duplicate files (same path in multiple packages)
163+
2. Compute approximate tarball and extracted sizes
164+
3. Track the longest relative file path (for MAX_PATH validation on Windows)
165+
166+
Args:
167+
pc_recs: Package cache records to check.
168+
platform: Target platform string (e.g., "win-64", "linux-64").
169+
duplicate_files: How to handle duplicates - "error", "warn", or "skip".
170+
env_prefixes: Optional dict mapping PackageCacheRecord -> path prefix string.
171+
Used to account for extra_envs paths which are installed under
172+
"envs/<name>/" rather than the base install directory. Records not
173+
in this dict are assumed to be in the base environment (no prefix).
174+
A trailing separator is added automatically if missing.
175+
176+
Returns:
177+
Tuple of (approx_tarball_size, approx_extracted_size, max_relative_path_length)
178+
"""
148179
assert duplicate_files in ("warn", "skip", "error")
180+
if env_prefixes is None:
181+
env_prefixes = {}
182+
for env, prefix in env_prefixes.items():
183+
if prefix and not prefix.endswith("/"):
184+
env_prefixes[env] += "/"
149185

150186
map_members_scase = defaultdict(set)
151187
map_members_icase = defaultdict(lambda: {"files": set(), "fns": set()})
152188

153189
# Keep a min, 50MB buffer size
154190
total_tarball_size = 52428800
155191
total_extracted_pkgs_size = 52428800
192+
max_relative_path_length = 0
156193

157194
for pc_rec in pc_recs:
158195
fn = pc_rec.fn
@@ -161,8 +198,12 @@ def check_duplicates_files(pc_recs, platform, duplicate_files="error"):
161198
total_tarball_size += int(pc_rec.get("size", 0))
162199

163200
paths_data = read_paths_json(extracted_package_dir).paths
201+
env_prefix_len = len(env_prefixes.get(pc_rec, ""))
164202
for path_data in paths_data:
165203
short_path = path_data.path
204+
max_relative_path_length = max(
205+
max_relative_path_length, env_prefix_len + len(short_path)
206+
)
166207
try:
167208
size = path_data.size_in_bytes or getsize(join(extracted_package_dir, short_path))
168209
except AttributeError:
@@ -176,7 +217,7 @@ def check_duplicates_files(pc_recs, platform, duplicate_files="error"):
176217
map_members_icase[short_path_lower]["fns"].add(fn)
177218

178219
if duplicate_files == "skip":
179-
return total_tarball_size, total_extracted_pkgs_size
220+
return total_tarball_size, total_extracted_pkgs_size, max_relative_path_length
180221

181222
logger.info("Checking for duplicate files ...")
182223
for member in map_members_scase:
@@ -201,7 +242,7 @@ def check_duplicates_files(pc_recs, platform, duplicate_files="error"):
201242
else:
202243
sys.exit(f"Error: {msg_str}")
203244

204-
return total_tarball_size, total_extracted_pkgs_size
245+
return total_tarball_size, total_extracted_pkgs_size, max_relative_path_length
205246

206247

207248
def _precs_from_environment(environment, input_dir):
@@ -443,18 +484,24 @@ def _main(
443484
input_dir=input_dir,
444485
)
445486
if dry_run:
446-
return None, None, None, None, None, None, None, None
487+
return None, None, None, None, None, None, None, None, None
447488
pc_recs, _urls, dists, has_conda = _fetch_precs(
448489
precs, download_dir, transmute_file_type=transmute_file_type
449490
)
450491
all_pc_recs = pc_recs.copy()
451492

452493
extra_envs_data = {}
494+
env_prefixes = {} # Maps pc_rec -> "envs/<name>/" prefix for max path calculation
453495
for env_name, env_precs in extra_envs_precs.items():
454496
env_pc_recs, env_urls, env_dists, _ = _fetch_precs(
455497
env_precs, download_dir, transmute_file_type=transmute_file_type
456498
)
457499
extra_envs_data[env_name] = {"_urls": env_urls, "_dists": env_dists, "_records": env_precs}
500+
env_prefix = f"envs/{env_name}/"
501+
for pc_rec in env_pc_recs:
502+
existing_prefix = env_prefixes.get(pc_rec, "")
503+
if len(env_prefix) > len(existing_prefix):
504+
env_prefixes[pc_rec] = env_prefix
458505
all_pc_recs += env_pc_recs
459506

460507
duplicate_files = "warn" if ignore_duplicate_files else "error"
@@ -463,8 +510,12 @@ def _main(
463510
duplicate_files = "skip"
464511

465512
all_pc_recs = list({rec: None for rec in all_pc_recs}) # deduplicate
466-
approx_tarballs_size, approx_pkgs_size = check_duplicates_files(
467-
pc_recs, platform, duplicate_files=duplicate_files
513+
# Pass all_pc_recs (base + extra_envs) to check_duplicates_files:
514+
# - When extra_envs exists, duplicate_files="skip" so only sizes and max path are computed
515+
# - When no extra_envs, all_pc_recs == pc_recs
516+
# - env_prefixes dict ensures max path accounts for "envs/<name>/" prefix in extra_envs
517+
approx_tarballs_size, approx_pkgs_size, max_relative_path_length = check_duplicates_files(
518+
all_pc_recs, platform, duplicate_files=duplicate_files, env_prefixes=env_prefixes
468519
)
469520

470521
return (
@@ -476,6 +527,7 @@ def _main(
476527
approx_pkgs_size,
477528
has_conda,
478529
extra_envs_data,
530+
max_relative_path_length,
479531
)
480532

481533

@@ -529,6 +581,7 @@ def main(info, verbose=True, dry_run=False, conda_exe="conda.exe"):
529581
approx_pkgs_size,
530582
has_conda,
531583
extra_envs_info,
584+
max_relative_path_length,
532585
) = _main(
533586
name,
534587
version,
@@ -561,3 +614,4 @@ def main(info, verbose=True, dry_run=False, conda_exe="conda.exe"):
561614
info["_has_conda"] = has_conda
562615
# contains {env_name: [_dists, _urls, _records]} for each extra environment
563616
info["_extra_envs_info"] = extra_envs_info
617+
info["_max_relative_path_length"] = max_relative_path_length

constructor/nsis/main.nsi.tmpl

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ ${Using:StrFunc} StrStr
130130
# are not written into install.log. STEP_LOG creates an intermittent file
131131
# that is output into these streams after the commands finish.
132132
!define STEP_LOG "$INSTDIR\.step.log"
133+
!define MAX_RELATIVE_PATH_LENGTH {{ max_relative_path_length }}
133134

134135
var /global INIT_CONDA
135136
var /global REG_PY
@@ -156,7 +157,6 @@ var /global ARGV_RegisterPython
156157
var /global ARGV_NoRegistry
157158
var /global ARGV_NoScripts
158159
var /global ARGV_NoShortcuts
159-
var /global ARGV_CheckPathLength
160160
var /global ARGV_QuietMode
161161
{%- if uninstall_with_conda_exe %}
162162
var /global ARGV_Uninst_RemoveConfigFiles
@@ -165,7 +165,6 @@ var /global ARGV_Uninst_RemoveCaches
165165
{%- endif %}
166166

167167
var /global IsDomainUser
168-
var /global CheckPathLength
169168
var /global LongPathsEnabled
170169
var /global InstDirLen
171170

@@ -292,7 +291,6 @@ Function SkipPageIfUACInnerInstance
292291
FunctionEnd
293292

294293
Function InitializeVariables
295-
StrCpy $CheckPathLength "{{ 1 if check_path_length else 0 }}"
296294
StrCpy $NO_REGISTRY "0"
297295

298296
# Package cache option
@@ -365,7 +363,6 @@ FunctionEnd
365363
/NoRegistry=[0|1] [default: AllUsers: 0, JustMe: 0]$\n\
366364
/NoScripts=[0|1] [default: 0]$\n\
367365
/NoShortcuts=[0|1] [default: 0]$\n\
368-
/CheckPathLength=[0|1] [default: {{ 1 if check_path_length else 0 }}]$\n\
369366
/? (show this help message)$\n\
370367
/S (run in CLI/headless mode)$\n\
371368
/Q (quiet mode, do not print output to console)$\n\
@@ -497,16 +494,6 @@ FunctionEnd
497494
StrCpy $Ana_CreateShortcuts_State ${BST_UNCHECKED}
498495
${EndIf}
499496

500-
ClearErrors
501-
${GetOptions} $ARGV "/CheckPathLength=" $ARGV_CheckPathLength
502-
${IfNot} ${Errors}
503-
${If} $ARGV_CheckPathLength = "0"
504-
StrCpy $CheckPathLength "0"
505-
${ElseIf} $ARGV_CheckPathLength = "1"
506-
StrCpy $CheckPathLength "1"
507-
${EndIf}
508-
${EndIf}
509-
510497
ClearErrors
511498
${GetOptions} $ARGV "/Q" $ARGV_QuietMode
512499
${IfNot} ${Errors}
@@ -1083,35 +1070,31 @@ Function OnDirectoryLeave
10831070
ReadRegStr $LongPathsEnabled HKLM "SYSTEM\CurrentControlSet\Control\FileSystem" "LongPathsEnabled"
10841071
StrLen $InstDirLen "$InstDir"
10851072

1086-
${If} $CheckPathLength == "1"
1087-
${AndIf} $LongPathsEnabled == "0"
1088-
${AndIf} $InstDirLen > 46
1089-
; With windows 10, we can enable support for long path, for earlier
1090-
; version, suggest user to use shorter installation path
1091-
${If} ${AtLeastWin10}
1092-
${AndIfNot} $NO_REGISTRY = "1"
1093-
; If we have admin right, we enable long path on windows
1094-
${If} ${UAC_IsAdmin}
1073+
# MAX_PATH check - installation will fail if path exceeds 260 characters
1074+
# If running as admin on Windows 10+, automatically enable long path support
1075+
${If} $LongPathsEnabled == "0"
1076+
IntOp $0 $InstDirLen + ${MAX_RELATIVE_PATH_LENGTH}
1077+
IntOp $0 $0 + 1 # Account for path separator between install dir and relative path
1078+
${If} $0 > 260
1079+
${If} ${AtLeastWin10}
1080+
${AndIfNot} $NO_REGISTRY = "1"
1081+
${AndIf} ${UAC_IsAdmin}
1082+
# Enable long path support via registry
10951083
WriteRegDWORD HKLM "SYSTEM\CurrentControlSet\Control\FileSystem" "LongPathsEnabled" 1
1096-
; If we don't have admin right, we suggest a shorter path or suggest to run with admin right
10971084
${Else}
1098-
${Print} "::error:: The installation path should be shorter than 46 characters or \
1099-
the installation requires administrator rights to enable long \
1100-
path on Windows."
1101-
MessageBox MB_OK|MB_ICONSTOP "The installation path should be shorter than 46 characters or \
1102-
the installation requires administrator rights to enable long \
1103-
path on Windows." \
1104-
/SD IDOK
1085+
IntOp $1 260 - ${MAX_RELATIVE_PATH_LENGTH}
1086+
IntOp $1 $1 - 1 # Account for path separator
1087+
${If} ${Silent}
1088+
${Print} "::error:: The installation path is too long. Your path is $InstDirLen characters, but must be at most $1 characters."
1089+
${Else}
1090+
MessageBox MB_OK|MB_ICONSTOP \
1091+
"The installation path is too long.$\n$\n\
1092+
Your path is $InstDirLen characters, but must be at most $1 characters.$\n$\n\
1093+
Please choose a shorter installation path." \
1094+
/SD IDOK
1095+
${EndIf}
11051096
Abort
11061097
${EndIf}
1107-
; If we don't have admin right, we suggest a shorter path or suggest to run with admin right
1108-
${Else}
1109-
${Print} "::error:: The installation path should be shorter than 46 characters. \
1110-
Please choose another location."
1111-
MessageBox MB_OK|MB_ICONSTOP "The installation path should be shorter than 46 characters. \
1112-
Please choose another location." \
1113-
/SD IDOK
1114-
Abort
11151098
${EndIf}
11161099
${EndIf}
11171100

constructor/winexe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ def make_nsi(
250250
variables.update(ns_platform(info["_platform"]))
251251
variables["initialize_conda"] = info.get("initialize_conda", "classic")
252252
variables["initialize_by_default"] = info.get("initialize_by_default", None)
253-
variables["check_path_length"] = info.get("check_path_length", False)
254253
variables["check_path_spaces"] = info.get("check_path_spaces", True)
254+
variables["max_relative_path_length"] = info.get("_max_relative_path_length", 0)
255255
variables["keep_pkgs"] = info.get("keep_pkgs") or False
256256
variables["pre_install_exists"] = bool(info.get("pre_install"))
257257
variables["post_install_exists"] = bool(info.get("post_install"))

docs/source/construct-yaml.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -552,11 +552,11 @@ interactive installation. (Windows only).
552552

553553
Only applies if `register_python` is true.
554554

555-
### `check_path_length`
555+
### ~~`check_path_length`~~
556556

557-
Check the length of the path where the distribution is installed to ensure nodejs
558-
can be installed. Raise a message to request shorter paths (less than 46 character)
559-
or enable long paths on windows > 10 (require admin right). Default is False. (Windows only).
557+
_Deprecated_. Path length validation is now always performed using the computed
558+
maximum relative path length from the package contents. This option will be
559+
removed in a future version. (Windows only).
560560

561561
### `check_path_spaces`
562562

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pytest
22
pytest-cov
3+
pytest-mock
34
coverage
45
jinja2
56
ruamel.yaml

tests/test_examples.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def _check_installer_log(install_dir):
162162
"Once you have installed it, set NSIS_USING_LOG_BUILD=1.\n"
163163
"Otherwise, this usually means that the destination folder could not be created.\n"
164164
"Possible causes: permissions, non-supported characters, long paths...\n"
165-
"Consider setting 'check_path_spaces' and 'check_path_length' to 'False'."
165+
"Consider setting 'check_path_spaces' to 'False'."
166166
)
167167
if error_lines:
168168
raise AssertionError("\n".join(error_lines))

0 commit comments

Comments
 (0)