-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Download prof files #2286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Download prof files #2286
Conversation
for more information, see https://pre-commit.ci
…-toolbar into download-prof-files
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds the ability to download profiling data as .prof files, addressing issue #1798. The feature is disabled by default and requires configuring the PROFILER_PROFILE_ROOT setting to specify where profile files should be saved.
Key changes:
- Added
PROFILER_PROFILE_ROOTconfiguration setting to control where profile files are saved - Implemented profile file generation in the profiling panel with signed filenames for security
- Created a new download view with URL endpoint to serve the profile files
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
debug_toolbar/settings.py |
Added PROFILER_PROFILE_ROOT default setting (None) |
debug_toolbar/panels/profiling.py |
Modified to generate and save .prof files when PROFILER_PROFILE_ROOT is configured; signs filename for secure download |
debug_toolbar/views.py |
Added download_prof_file view to handle file downloads with signature validation |
debug_toolbar/toolbar.py |
Registered new download URL endpoint |
debug_toolbar/urls.py |
Minor whitespace addition |
debug_toolbar/templates/debug_toolbar/panels/profiling.html |
Added download link UI and reformatted indentation |
debug_toolbar/static/debug_toolbar/css/toolbar.css |
Added styling for download control |
tests/panels/test_profiling.py |
Added comprehensive tests for profile file generation and download functionality |
docs/configuration.rst |
Documented the new PROFILER_PROFILE_ROOT setting |
docs/changes.rst |
Added changelog entry for the new feature |
debug_toolbar/panels/sql/tracking.py |
Unrelated changes to handle executemany parameters in SQL tracking |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tempfile.gettempdir()} | ||
| ) | ||
| def test_generate_stats_signed_path(self): | ||
| response = self.panel.process_request(self.request) | ||
| self.panel.generate_stats(self.request, response) | ||
| path = self.panel.prof_file_path | ||
| self.assertTrue(path) | ||
| # Check that it's a valid signature | ||
| filename = signing.loads(path) | ||
| self.assertTrue(filename.endswith(".prof")) |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test cleanup issue: The test_generate_stats_signed_path creates a .prof file in tempfile.gettempdir() but doesn't clean it up after the test. Consider adding cleanup in a tearDown method or using a temporary directory that gets cleaned up automatically (similar to ProfilingDownloadViewTestCase which uses tempfile.mkdtemp and shutil.rmtree).
| resolved_path = pathlib.Path(root) / filename | ||
| if not resolved_path.exists(): |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Path traversal vulnerability: The code does not validate that the resolved path stays within the configured root directory. An attacker could sign a filename like "../../../etc/passwd" to access files outside the intended directory. Add validation using resolved_path.resolve().is_relative_to(pathlib.Path(root).resolve()) to ensure the file path is within the allowed directory.
| resolved_path = pathlib.Path(root) / filename | |
| if not resolved_path.exists(): | |
| root_path = pathlib.Path(root).resolve() | |
| resolved_path = (root_path / filename).resolve() | |
| if not resolved_path.is_relative_to(root_path) or not resolved_path.exists(): |
| raise Http404() | ||
|
|
||
| response = FileResponse( | ||
| open(resolved_path, "rb"), content_type="application/octet-stream" |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resource leak: The file opened with open(resolved_path, "rb") is never explicitly closed. While FileResponse will eventually close it, it's better to use FileResponse with a path string or use a context manager to ensure proper resource cleanup. Consider using FileResponse(resolved_path, ...) which accepts a path-like object and handles file closing automatically.
| open(resolved_path, "rb"), content_type="application/octet-stream" | |
| resolved_path, content_type="application/octet-stream" |
| if ( | ||
| root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"] | ||
| ) and os.path.exists(root): | ||
| filename = f"{uuid.uuid4().hex}.prof" | ||
| prof_file_path = os.path.join(root, filename) | ||
| self.profiler.dump_stats(prof_file_path) | ||
| self.prof_file_path = signing.dumps(filename) | ||
|
|
||
| root_func = cProfile.label(super().process_request.__code__) | ||
| if root_func in self.stats.stats: | ||
| root = FunctionCall(self.stats, root_func, depth=0) |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable name collision: The variable 'root' on line 189 shadows the 'root' variable used on line 198 for the FunctionCall object. This makes the code confusing to read. Consider renaming the root directory variable to something like 'profile_root' or 'root_dir' to avoid confusion.
| path( | ||
| "download_prof_file/", | ||
| views.download_prof_file, | ||
| name="debug_toolbar_download_prof_file", |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URL name inconsistency: The URL name "debug_toolbar_download_prof_file" is inconsistent with the naming convention used by other URLs in this codebase. Other URLs use simple names without the "debug_toolbar_" prefix (e.g., "render_panel" on line 165, "history_sidebar", "sql_select", "template_source"). Consider renaming to just "download_prof_file" for consistency.
| name="debug_toolbar_download_prof_file", | |
| name="download_prof_file", |
| open(resolved_path, "rb"), content_type="application/octet-stream" | ||
| ) | ||
| response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"' |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential header injection vulnerability: The filename in the Content-Disposition header is not properly escaped. If a malicious filename contains quotes or newlines, it could lead to header injection attacks. Consider using Django's http.urlquote or properly escaping the filename value, or use Django's FileResponse which can handle this automatically when you pass the filename parameter instead of setting the header manually.
| open(resolved_path, "rb"), content_type="application/octet-stream" | |
| ) | |
| response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"' | |
| open(resolved_path, "rb"), | |
| as_attachment=True, | |
| filename=resolved_path.name, | |
| content_type="application/octet-stream", | |
| ) |
| scripts = panel.scripts | ||
| return JsonResponse({"content": content, "scripts": scripts}) | ||
|
|
||
|
|
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The download_prof_file view is missing authentication and authorization decorators. The render_panel view on line 16-18 uses @login_not_required, @require_show_toolbar, and @render_with_toolbar_language decorators. At minimum, this view should have @login_not_required and @require_show_toolbar decorators to ensure only authorized users can download profile files.
| @login_not_required | |
| @require_show_toolbar |
| # Handle executemany: take the first set of parameters for formatting | ||
| if ( | ||
| isinstance(params, (list, tuple)) | ||
| and len(params) > 0 | ||
| and isinstance(params[0], (list, tuple)) | ||
| ): | ||
| sample_params = params[0] | ||
| else: | ||
| sample_params = params | ||
|
|
||
| try: | ||
| return self.db.ops.last_executed_query(self.cursor, sql, sample_params) | ||
| except Exception: | ||
| return sql |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes to handle executemany parameters appear unrelated to the PR's stated purpose of adding profile file downloads. This change modifies SQL query logging behavior and should be in a separate PR. If this is intentionally included, the PR description should explain why these SQL tracking changes are necessary for the profiling download feature.
| class ProfilingDownloadViewTestCase(TestCase): | ||
| def setUp(self): | ||
| self.root = tempfile.mkdtemp() | ||
| self.filename = "test.prof" | ||
| self.filepath = os.path.join(self.root, self.filename) | ||
| with open(self.filepath, "wb") as f: | ||
| f.write(b"data") | ||
| self.signed_path = signing.dumps(self.filename) | ||
|
|
||
| def tearDown(self): | ||
| shutil.rmtree(self.root) | ||
|
|
||
| def test_download_no_root_configured(self): | ||
| response = self.client.get(reverse("djdt:debug_toolbar_download_prof_file")) | ||
| self.assertEqual(response.status_code, 404) | ||
|
|
||
| def test_download_valid(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| response = self.client.get(url, {"path": self.signed_path}) | ||
| self.assertEqual(response.status_code, 200) | ||
| self.assertEqual(list(response.streaming_content), [b"data"]) | ||
|
|
||
| def test_download_invalid_signature(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| # Tamper with the signature | ||
| response = self.client.get(url, {"path": self.signed_path + "bad"}) | ||
| self.assertEqual(response.status_code, 404) | ||
|
|
||
| def test_download_missing_file(self): | ||
| with override_settings( | ||
| DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root} | ||
| ): | ||
| url = reverse("djdt:debug_toolbar_download_prof_file") | ||
| # Sign a filename that doesn't exist | ||
| path = signing.dumps("missing.prof") | ||
| response = self.client.get(url, {"path": path}) | ||
| self.assertEqual(response.status_code, 404) |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test for path traversal attack: The test suite should include a test that verifies path traversal attempts (e.g., signing "../../../etc/passwd" or "subdir/../../../etc/passwd") are properly rejected and return 404. This is important for security validation.
| if ( | ||
| root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"] | ||
| ) and os.path.exists(root): | ||
| filename = f"{uuid.uuid4().hex}.prof" | ||
| prof_file_path = os.path.join(root, filename) | ||
| self.profiler.dump_stats(prof_file_path) | ||
| self.prof_file_path = signing.dumps(filename) |
Copilot
AI
Dec 17, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for profiler.dump_stats(): If the dump_stats call fails (e.g., due to disk full, permissions error, or directory deleted after existence check), the prof_file_path will not be set but no error will be logged or reported. Consider wrapping the dump_stats call in a try-except block to gracefully handle failures and log errors, so the profiling panel continues to work even if file saving fails.
Description
This pr is based on the work of @andoriyaprashant which he made to fix #1798. I am continuing his work to get it over the finish line. His pr is this #2146 .
Fixes #1798
Checklist:
docs/changes.rst.