diff --git a/CHANGELOG.md b/CHANGELOG.md index fb41eee..0173a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 7.0.0 + +### Breaking Changes + +- **`datacustomcode zip` and `datacustomcode deploy` no longer package symlinks.** + + Symlinks found inside the payload directory are skipped instead of being followed. A symlinked file logs a `Skipping symlink file ` warning and is left out of `deployment.zip`. + + **Why:** Following a symlink packaged the bytes of its target into the archive, even when the target was outside the payload (for example `payload/notes.txt -> ~/.ssh/id_rsa`). + + **Migration:** Replace any symlink in the payload with a real copy of the file or directory it points to: + + ```bash + # Before + ln -s ../shared/helpers.py payload/helpers.py + + # After + cp ../shared/helpers.py payload/helpers.py + ``` + ## 6.1.0 ### Added diff --git a/README.md b/README.md index 1f90472..e7df12a 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,8 @@ Options: #### `datacustomcode zip` Zip a transformation job in preparation to upload to Data Cloud. Make sure to change directory into your code package folder (e.g., `my_package`) before running this command. +Symlinks inside the payload folder are not followed or packaged. Each symlinked file that is skipped is reported with a warning, and symlinked directories are not included. `datacustomcode deploy` zips the same way. + Arguments: - `PATH`: Path to the code directory i.e. the payload folder (default: "payload") diff --git a/src/datacustomcode/deploy.py b/src/datacustomcode/deploy.py index 881be26..2354bdf 100644 --- a/src/datacustomcode/deploy.py +++ b/src/datacustomcode/deploy.py @@ -630,12 +630,15 @@ def zip( logger.debug(f"Zipping directory... {directory}") with zipfile.ZipFile(ZIP_FILE_NAME, "w", zipfile.ZIP_DEFLATED) as zipf: - for root, dirs, files in os.walk(directory): - # Skip .DS_Store and local credentials. + for root, dirs, files in os.walk(directory, followlinks=False): + # Skip symlinks, .DS_Store and local credentials. for file in files: + abs_path = os.path.join(root, file) + arcname = os.path.relpath(abs_path, directory) + if os.path.islink(abs_path): + logger.warning(f"Skipping symlink file {arcname}") + continue if file not in EXCLUDED_FILES: - abs_path = os.path.join(root, file) - arcname = os.path.relpath(abs_path, directory) zipf.write(abs_path, arcname) logger.debug(f"Created zip file: {ZIP_FILE_NAME}") diff --git a/tests/test_deploy.py b/tests/test_deploy.py index c592bba..49bcc46 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -841,6 +841,44 @@ def test_zip_excludes_ds_store_and_external_credentials( assert not any(".DS_Store" in path for path in written) +class TestZipSymlinks: + """zip() must skip symlinked files instead of following them into the archive.""" + + @pytest.fixture + def mocks(self): + with ( + patch( + "datacustomcode.deploy.has_nonempty_requirements_file", + return_value=False, + ), + patch("zipfile.ZipFile") as mock_zipfile, + patch("os.walk") as mock_walk, + ): + yield mock_walk, mock_zipfile.return_value.__enter__.return_value + + def test_zip_skips_symlinked_files(self, mocks): + mock_walk, mock_zipfile_instance = mocks + mock_walk.return_value = [ + ("/test/dir", ["subdir"], ["entrypoint.py", "linked.txt"]), + ("/test/dir/subdir", [], ["nested.py", "linked.py"]), + ] + links = {"/test/dir/linked.txt", "/test/dir/subdir/linked.py"} + + with patch("os.path.islink", side_effect=lambda path: path in links): + zip("/test/dir", "default", "script") + + written = [call.args[0] for call in mock_zipfile_instance.write.call_args_list] + assert written == ["/test/dir/entrypoint.py", "/test/dir/subdir/nested.py"] + + def test_zip_skips_symlinked_directories(self, mocks): + mock_walk, _ = mocks + mock_walk.return_value = [] + + zip("/test/dir", "default", "script") + + mock_walk.assert_called_once_with("/test/dir", followlinks=False) + + class TestUploadZip: @patch("datacustomcode.deploy.requests.put") @patch("builtins.open", new_callable=mock_open, read_data=b"test data")