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
43 changes: 43 additions & 0 deletions python/tank/util/zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# not expressly granted therein are reserved by Shotgun Software Inc.

import os
import sys
import zipfile

from .. import LogManager
Expand Down Expand Up @@ -102,6 +103,44 @@ def zip_file(source_folder, target_zip_file):
log.debug("Zip complete. Size: %s" % os.path.getsize(target_zip_file))


def _to_extended_path(path: str) -> str:
"""
On Windows, prepend the extended-length path prefix to paths >= 260
characters to bypass the MAX_PATH limitation.

Extended-length paths come in two flavours:

* Drive-letter paths (``C:\\...``) are prefixed with ``\\\\?\\``.
* UNC paths (``\\\\server\\share\\...``) require the ``\\\\?\\UNC\\``
prefix; naively prepending ``\\\\?\\`` produces an invalid path.

Drive-less rooted paths (``\\foo``) are not eligible for the prefix
because the extended-length syntax requires a fully-qualified path.

:param path: Normalised path string.
:returns: Path with the appropriate extended-length prefix on Windows
when necessary, otherwise the original path unchanged.
"""
if sys.platform != "win32" or len(path) < 260:
return path

if path.startswith("\\\\?\\"):
# Already an extended-length path - don't double-prefix.
return path

if path.startswith("\\\\"):
# UNC path (\\\\server\\share\\...). The extended-length form requires
# \\\\?\\UNC\\ rather than \\\\?\\\\\\\\
return "\\\\?\\UNC\\" + path[2:]

if len(path) >= 3 and path[1] == ":" and path[2] == "\\":
# Fully-qualified drive-letter path (C:\\...).
return "\\\\?\\" + path

# Drive-less rooted paths (\\foo) or relative paths are not eligible.
return path


def _process_item(zip_obj, item_path, target_path, root_to_omit=None):
"""
Helper method used by unzip_file()
Expand Down Expand Up @@ -143,6 +182,10 @@ def _process_item(zip_obj, item_path, target_path, root_to_omit=None):

target_path = os.path.normpath(target_path)

# On Windows, use the extended-length path prefix for paths >= 260 characters
# to avoid MAX_PATH limitations.
target_path = _to_extended_path(target_path)

Comment thread
carlos-villavicencio-adsk marked this conversation as resolved.
# Create all upper directories if necessary.
upperdirs = os.path.dirname(target_path)
if upperdirs and not os.path.exists(upperdirs):
Expand Down
60 changes: 60 additions & 0 deletions tests/util_tests/test_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# not expressly granted therein are reserved by Shotgun Software Inc.

import os
import sys
import unittest

import tank
from tank_test.tank_test_base import ShotgunTestBase, setUpModule # noqa
Expand Down Expand Up @@ -119,3 +121,61 @@ def test_single_file_unzip(self):
self.assertEqual(
set(get_file_list(output_path_2, output_path_2)), set(["/info.yml"])
)


class TestToExtendedPath(ShotgunTestBase):
"""
Tests the tank.util.zip._to_extended_path() helper.
"""

_LONG_ABS_PATH = "C:\\" + "a" * 257 # 260 chars, drive-letter absolute
_LONG_UNC_PATH = "\\\\server\\share\\" + "a" * 245 # 260 chars, UNC

def test_short_path_unchanged(self):
"""A path under 260 characters is returned unchanged on all platforms."""
path = "C:\\short\\path\\file.txt"
self.assertEqual(tank.util.zip._to_extended_path(path), path)

def test_relative_path_unchanged(self):
"""A relative path >= 260 characters must NOT receive the prefix."""
path = "relative\\" + "a" * 257 # long but relative
self.assertFalse(os.path.isabs(path))
self.assertEqual(tank.util.zip._to_extended_path(path), path)

@unittest.skipUnless(sys.platform == "win32", "Windows-only behaviour")
def test_long_absolute_path_prefixed_on_windows(self):
"""A long drive-letter path on Windows receives the \\\\?\\ prefix."""
result = tank.util.zip._to_extended_path(self._LONG_ABS_PATH)
self.assertTrue(result.startswith("\\\\?\\"))
self.assertEqual(result, "\\\\?\\" + self._LONG_ABS_PATH)

Comment thread
carlos-villavicencio-adsk marked this conversation as resolved.
@unittest.skipUnless(sys.platform == "win32", "Windows-only behaviour")
def test_already_prefixed_path_unchanged(self):
"""A path that already has the \\\\?\\ prefix is not double-prefixed."""
prefixed = "\\\\?\\" + self._LONG_ABS_PATH
self.assertEqual(tank.util.zip._to_extended_path(prefixed), prefixed)

@unittest.skipUnless(sys.platform == "win32", "Windows-only behaviour")
def test_long_unc_path_prefixed_with_unc_prefix(self):
"""A long UNC path receives \\\\?\\UNC\\ prefix, not \\\\?\\\\\\\\."""
result = tank.util.zip._to_extended_path(self._LONG_UNC_PATH)
self.assertTrue(result.startswith("\\\\?\\UNC\\"))
self.assertEqual(result, "\\\\?\\UNC\\" + self._LONG_UNC_PATH[2:])

@unittest.skipUnless(sys.platform == "win32", "Windows-only behaviour")
def test_already_extended_unc_path_unchanged(self):
"""A path already using \\\\?\\UNC\\ is not double-prefixed."""
prefixed = "\\\\?\\UNC\\" + self._LONG_UNC_PATH[2:]
self.assertEqual(tank.util.zip._to_extended_path(prefixed), prefixed)

@unittest.skipUnless(sys.platform == "win32", "Windows-only behaviour")
def test_drive_less_rooted_path_unchanged(self):
"""A drive-less rooted path (\\foo) must NOT receive the prefix."""
path = "\\" + "a" * 259 # rooted but no drive letter, >= 260 chars
self.assertEqual(tank.util.zip._to_extended_path(path), path)

@unittest.skipIf(sys.platform == "win32", "Non-Windows behaviour")
def test_long_absolute_path_unchanged_on_non_windows(self):
"""A long absolute path on non-Windows platforms is returned unchanged."""
path = "/" + "a" * 260
self.assertEqual(tank.util.zip._to_extended_path(path), path)