Skip to content

Commit e95bb8b

Browse files
committed
Reject duplicate multipart upload keys before sending files
1 parent 809722c commit e95bb8b

6 files changed

Lines changed: 360 additions & 151 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 3.6.3
4+
5+
### Fixed: reject duplicate multipart upload keys
6+
7+
- Manifest uploads and full scans now raise `ValueError` before sending a
8+
request when multiple files map to the same multipart field name. The error
9+
includes the colliding key and both source paths; files are never silently
10+
dropped. Key calculation is shared by lazy and eager manifest uploads.
11+
312
## 3.6.2
413

514
### Fixed: reject retired API credentials before use

‎socketdev/fullscans/__init__.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,9 @@ def post(
871871
Returns:
872872
dict or CreateFullScanResponse: API response containing scan results
873873
874+
Raises:
875+
ValueError: If two files have the same multipart upload key.
876+
874877
Note:
875878
When use_lazy_loading=True, files are opened only when needed during upload,
876879
preventing file descriptor exhaustion. The max_open_files parameter controls how many
@@ -902,7 +905,25 @@ def post(
902905
if use_lazy_loading:
903906
prepared_files = Utils.load_files_for_sending_lazy(files, workspace, max_open_files, base_path, base_paths)
904907
else:
905-
prepared_files = files
908+
# This mode accepts requests-style multipart entries already prepared by callers.
909+
is_mapping = hasattr(files, "items")
910+
entries = list(files.items()) if is_mapping else list(files or [])
911+
key_sources = []
912+
for key, value in entries:
913+
if isinstance(value, (tuple, list)):
914+
filename, content = value[:2]
915+
source = getattr(content, "name", filename)
916+
else:
917+
content = value
918+
source = getattr(content, "name", key)
919+
# Requests omits parts with no content and decodes byte field names as UTF-8.
920+
if content is None:
921+
continue
922+
normalized_key = key.decode("utf-8") if isinstance(key, bytes) else key
923+
key_sources.append((normalized_key, source))
924+
Utils.validate_upload_keys(key_sources)
925+
# Validation must not consume a one-shot iterable before Requests encodes it.
926+
prepared_files = files if files is None or is_mapping or isinstance(files, (list, tuple)) else entries
906927

907928
response = self.api.do_request(path=path, method="POST", files=prepared_files)
908929

‎socketdev/uploadmanifests/__init__.py‎

Lines changed: 5 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -11,77 +11,8 @@ def __init__(self, api):
1111
self.api = api
1212

1313
def _calculate_key_name(self, file_path: str, workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> str:
14-
"""
15-
Calculate the key name for a file using the same logic as load_files_for_sending_lazy.
16-
This ensures consistency between lazy and non-lazy loading modes.
17-
"""
18-
# Normalize file path
19-
if "\\" in file_path:
20-
file_path = file_path.replace("\\", "/")
21-
22-
# Normalize paths
23-
if workspace and "\\" in workspace:
24-
workspace = workspace.replace("\\", "/")
25-
if base_path and "\\" in base_path:
26-
base_path = base_path.replace("\\", "/")
27-
if base_paths:
28-
base_paths = [bp.replace("\\", "/") if "\\" in bp else bp for bp in base_paths]
29-
30-
# Calculate the key name for the form data
31-
key = file_path
32-
path_stripped = False
33-
34-
# If base_paths is provided, try to strip one of the paths from the file path
35-
if base_paths:
36-
for bp in base_paths:
37-
normalized_base_path = bp.rstrip("/") + "/" if not bp.endswith("/") else bp
38-
if key.startswith(normalized_base_path):
39-
key = key[len(normalized_base_path):]
40-
path_stripped = True
41-
break
42-
elif key.startswith(bp.rstrip("/")):
43-
stripped_base = bp.rstrip("/")
44-
if key.startswith(stripped_base + "/") or key == stripped_base:
45-
key = key[len(stripped_base):]
46-
key = key.lstrip("/")
47-
path_stripped = True
48-
break
49-
elif base_path:
50-
normalized_base_path = base_path.rstrip("/") + "/" if not base_path.endswith("/") else base_path
51-
if key.startswith(normalized_base_path):
52-
key = key[len(normalized_base_path):]
53-
path_stripped = True
54-
elif key.startswith(base_path.rstrip("/")):
55-
stripped_base = base_path.rstrip("/")
56-
if key.startswith(stripped_base + "/") or key == stripped_base:
57-
key = key[len(stripped_base):]
58-
key = key.lstrip("/")
59-
path_stripped = True
60-
61-
# If workspace is provided and no base paths matched, fall back to workspace logic
62-
if not path_stripped and workspace and file_path.startswith(workspace):
63-
key = file_path[len(workspace):]
64-
# Remove all leading slashes (for absolute paths)
65-
while key.startswith("/"):
66-
key = key[1:]
67-
path_stripped = True
68-
69-
# Clean up relative path prefixes, but preserve filename dots
70-
while key.startswith("./"):
71-
key = key[2:]
72-
while key.startswith("../"):
73-
key = key[3:]
74-
# Remove any remaining leading slashes (for absolute paths)
75-
while key.startswith("/"):
76-
key = key[1:]
77-
78-
# Remove Windows drive letter if present (C:/...)
79-
if len(key) > 2 and key[1] == ':' and (key[2] == '/' or key[2] == '\\'):
80-
key = key[2:]
81-
while key.startswith("/"):
82-
key = key[1:]
83-
84-
return key
14+
"""Calculate a multipart key using the shared upload path logic."""
15+
return Utils.calculate_upload_key(file_path, workspace, base_path, base_paths)
8516

8617
def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None, use_lazy_loading: bool = True) -> str:
8718
"""
@@ -99,6 +30,7 @@ def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace:
9930
str: The tarHash from the upload response
10031
10132
Raises:
33+
ValueError: If two source paths produce the same upload key.
10234
Exception: If upload fails
10335
"""
10436
# Filter to only existing files
@@ -118,9 +50,8 @@ def upload_manifest_files(self, org_slug: str, file_paths: List[str], workspace:
11850
else:
11951
# Fallback to basic file loading if needed
12052
loaded_files = []
121-
for file_path in valid_files:
122-
# Use the same key generation logic as lazy loading for consistency
123-
key = self._calculate_key_name(file_path, workspace, base_path, base_paths)
53+
key_sources = Utils.prepare_upload_keys(valid_files, workspace, base_path, base_paths)
54+
for key, file_path in key_sources:
12455
with open(file_path, 'rb') as f:
12556
loaded_files.append((key, (key, f.read())))
12657

‎socketdev/utils/__init__.py‎

Lines changed: 108 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Literal, List, Tuple, Optional, Union
2+
from urllib3.fields import RequestField
23
import logging
34
import os
45
import weakref
@@ -238,6 +239,105 @@ def validate_integration_type(integration_type: str) -> IntegrationType:
238239
raise ValueError(f"Invalid integration type: {integration_type}")
239240
return integration_type # type: ignore
240241

242+
@staticmethod
243+
def calculate_upload_key(file_path: str, workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> str:
244+
"""Calculate the repo-relative multipart field name for a manifest."""
245+
workspace, base_path, base_paths = Utils._normalize_upload_options(workspace, base_path, base_paths)
246+
return Utils._calculate_normalized_upload_key(file_path.replace("\\", "/"), workspace, base_path, base_paths)
247+
248+
@staticmethod
249+
def _normalize_upload_options(workspace, base_path, base_paths):
250+
return (
251+
workspace.replace("\\", "/") if workspace else workspace,
252+
base_path.replace("\\", "/") if base_path else base_path,
253+
[path.replace("\\", "/") for path in base_paths] if base_paths else base_paths,
254+
)
255+
256+
@staticmethod
257+
def _calculate_normalized_upload_key(file_path, workspace, base_path, base_paths):
258+
# Calculate the key name for the form data
259+
key = file_path
260+
path_stripped = False
261+
262+
# If base_paths is provided, try to strip one of the paths from the file path
263+
if base_paths:
264+
for bp in base_paths:
265+
normalized_base_path = bp.rstrip("/") + "/" if not bp.endswith("/") else bp
266+
if key.startswith(normalized_base_path):
267+
key = key[len(normalized_base_path):]
268+
path_stripped = True
269+
break
270+
elif key.startswith(bp.rstrip("/")):
271+
stripped_base = bp.rstrip("/")
272+
if key.startswith(stripped_base + "/") or key == stripped_base:
273+
key = key[len(stripped_base):]
274+
key = key.lstrip("/")
275+
path_stripped = True
276+
break
277+
elif base_path:
278+
normalized_base_path = base_path.rstrip("/") + "/" if not base_path.endswith("/") else base_path
279+
if key.startswith(normalized_base_path):
280+
key = key[len(normalized_base_path):]
281+
path_stripped = True
282+
elif key.startswith(base_path.rstrip("/")):
283+
stripped_base = base_path.rstrip("/")
284+
if key.startswith(stripped_base + "/") or key == stripped_base:
285+
key = key[len(stripped_base):]
286+
key = key.lstrip("/")
287+
path_stripped = True
288+
289+
# If workspace is provided and no base paths matched, fall back to workspace logic
290+
if not path_stripped and workspace and file_path.startswith(workspace):
291+
key = file_path[len(workspace):]
292+
# Remove all leading slashes (for absolute paths)
293+
while key.startswith("/"):
294+
key = key[1:]
295+
path_stripped = True
296+
297+
# Clean up relative path prefixes, but preserve filename dots
298+
while key.startswith("./"):
299+
key = key[2:]
300+
while key.startswith("../"):
301+
key = key[3:]
302+
# Remove any remaining leading slashes (for absolute paths)
303+
while key.startswith("/"):
304+
key = key[1:]
305+
306+
# Remove Windows drive letter if present (C:/...)
307+
if len(key) > 2 and key[1] == ':' and (key[2] == '/' or key[2] == '\\'):
308+
key = key[2:]
309+
while key.startswith("/"):
310+
key = key[1:]
311+
312+
return key
313+
314+
@staticmethod
315+
def validate_upload_keys(key_sources: List[Tuple[str, str]]) -> None:
316+
"""Reject colliding multipart keys, preserving source paths for diagnostics."""
317+
seen = {}
318+
formatter = RequestField(name="", data=None).header_formatter
319+
for key, source in key_sources:
320+
if key is None:
321+
continue
322+
# Use the installed encoder's escaping rules; the result is name="<key>".
323+
wire_key = formatter("name", key)[6:-1]
324+
if wire_key in seen:
325+
raise ValueError(
326+
f"Duplicate upload key '{wire_key}' from '{seen[wire_key]}' and '{source}'"
327+
)
328+
seen[wire_key] = source
329+
330+
@staticmethod
331+
def prepare_upload_keys(files: List[str], workspace: Optional[str] = None, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> List[Tuple[str, str]]:
332+
"""Compute and validate all keys before reading any file contents."""
333+
workspace, base_path, base_paths = Utils._normalize_upload_options(workspace, base_path, base_paths)
334+
key_sources = [
335+
(Utils._calculate_normalized_upload_key(path.replace("\\", "/"), workspace, base_path, base_paths), path)
336+
for path in files
337+
]
338+
Utils.validate_upload_keys(key_sources)
339+
return key_sources
340+
241341
@staticmethod
242342
def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = None, max_open_files: int = 100, base_path: Optional[str] = None, base_paths: Optional[List[str]] = None) -> List[Tuple[str, Tuple[str, LazyFileLoader]]]:
243343
"""
@@ -258,86 +358,19 @@ def load_files_for_sending_lazy(files: List[str], workspace: Optional[str] = Non
258358
Returns:
259359
List of tuples formatted for requests multipart upload:
260360
[(field_name, (filename, lazy_file_object)), ...]
361+
362+
Raises:
363+
ValueError: If two source paths produce the same upload key.
261364
"""
262365
# Configure the file descriptor manager with the specified limit
263366
_fd_manager.set_max_open_files(max_open_files)
264367

265368
send_files = []
266-
if workspace and "\\" in workspace:
267-
workspace = workspace.replace("\\", "/")
268-
if base_path and "\\" in base_path:
269-
base_path = base_path.replace("\\", "/")
270-
if base_paths:
271-
base_paths = [bp.replace("\\", "/") if "\\" in bp else bp for bp in base_paths]
272-
273-
for file_path in files:
274-
# Normalize file path
275-
if "\\" in file_path:
276-
file_path = file_path.replace("\\", "/")
277-
278-
# Skip directories
279-
if os.path.isdir(file_path):
280-
continue
281-
282-
# Handle file path splitting safely
283-
if "/" in file_path:
284-
_, name = file_path.rsplit("/", 1)
285-
else:
286-
name = file_path
287-
288-
# Calculate the key name for the form data
289-
key = file_path
290-
path_stripped = False
291-
292-
# If base_paths is provided, try to strip one of the paths from the file path
293-
if base_paths:
294-
for bp in base_paths:
295-
normalized_base_path = bp.rstrip("/") + "/" if not bp.endswith("/") else bp
296-
if key.startswith(normalized_base_path):
297-
key = key[len(normalized_base_path):]
298-
path_stripped = True
299-
break
300-
elif key.startswith(bp.rstrip("/")):
301-
stripped_base = bp.rstrip("/")
302-
if key.startswith(stripped_base + "/") or key == stripped_base:
303-
key = key[len(stripped_base):]
304-
key = key.lstrip("/")
305-
path_stripped = True
306-
break
307-
elif base_path:
308-
normalized_base_path = base_path.rstrip("/") + "/" if not base_path.endswith("/") else base_path
309-
if key.startswith(normalized_base_path):
310-
key = key[len(normalized_base_path):]
311-
path_stripped = True
312-
elif key.startswith(base_path.rstrip("/")):
313-
stripped_base = base_path.rstrip("/")
314-
if key.startswith(stripped_base + "/") or key == stripped_base:
315-
key = key[len(stripped_base):]
316-
key = key.lstrip("/")
317-
path_stripped = True
318-
319-
# If workspace is provided and no base paths matched, fall back to workspace logic
320-
if not path_stripped and workspace and file_path.startswith(workspace):
321-
key = file_path[len(workspace):]
322-
# Remove all leading slashes (for absolute paths)
323-
while key.startswith("/"):
324-
key = key[1:]
325-
path_stripped = True
326-
327-
# Clean up relative path prefixes, but preserve filename dots
328-
while key.startswith("./"):
329-
key = key[2:]
330-
while key.startswith("../"):
331-
key = key[3:]
332-
# Remove any remaining leading slashes (for absolute paths)
333-
while key.startswith("/"):
334-
key = key[1:]
335-
336-
# Remove Windows drive letter if present (C:/...)
337-
if len(key) > 2 and key[1] == ':' and (key[2] == '/' or key[2] == '\\'):
338-
key = key[2:]
339-
while key.startswith("/"):
340-
key = key[1:]
369+
# Directories do not produce multipart parts, so exclude them before validating keys.
370+
upload_files = [path for path in files if not os.path.isdir(path.replace("\\", "/"))]
371+
key_sources = Utils.prepare_upload_keys(upload_files, workspace, base_path, base_paths)
372+
for key, source_path in key_sources:
373+
file_path = source_path.replace("\\", "/")
341374

342375
# Create lazy file loader instead of opening file immediately
343376
lazy_file = LazyFileLoader(file_path, key)

‎socketdev/version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "3.6.2"
1+
__version__ = "3.6.3"

0 commit comments

Comments
 (0)