mkdocs-zip-bundle-plugin 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {mkdocs_zip_bundle_plugin-0.2.0/mkdocs_zip_bundle_plugin.egg-info → mkdocs_zip_bundle_plugin-0.3.0}/PKG-INFO +2 -2
  2. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.js +37 -22
  3. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/plugin.py +18 -11
  4. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0/mkdocs_zip_bundle_plugin.egg-info}/PKG-INFO +2 -2
  5. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/pyproject.toml +2 -2
  6. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_plugin.py +24 -1
  7. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/LICENSE +0 -0
  8. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/README.md +0 -0
  9. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/__init__.py +0 -0
  10. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/jszip.min.js +0 -0
  11. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.css +0 -0
  12. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/SOURCES.txt +0 -0
  13. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/dependency_links.txt +0 -0
  14. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/entry_points.txt +0 -0
  15. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/requires.txt +0 -0
  16. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/top_level.txt +0 -0
  17. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/setup.cfg +0 -0
  18. {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_integration.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-zip-bundle-plugin
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical.
5
5
  Author: Daemonless
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
8
  Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
9
  Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
@@ -5,12 +5,19 @@
5
5
  // so on MkDocs/Material (server-injected) this is a harmless no-op.
6
6
 
7
7
  function sanitizeFilename(filename) {
8
+ if (!filename) return '';
8
9
  // Match plugin._sanitize_filename: prevent path traversal, allow subdirs.
9
- filename = filename.replace(/\\/g, '/'); // backslashes -> forward
10
- filename = filename.replace(/\.\.(?=\/|$)/g, ''); // drop ".." segments
11
- filename = filename.replace(/^\/+/, ''); // no leading slashes
12
- filename = filename.replace(/\/+/g, '/'); // collapse slashes
13
- return filename.trim();
10
+ filename = filename.replace(/\0/g, '').replace(/\\/g, '/');
11
+ const parts = [];
12
+ for (let part of filename.split('/')) {
13
+ part = part.trim();
14
+ // Drop empty parts and segments consisting only of dots (., .., ..., etc.)
15
+ if (!part || /^\.+$/.test(part)) continue;
16
+ // Strip Windows drive letters (e.g. C:)
17
+ if (/^[a-zA-Z]:$/.test(part)) continue;
18
+ parts.push(part);
19
+ }
20
+ return parts.join('/');
14
21
  }
15
22
 
16
23
  function titleCase(str) {
@@ -36,8 +43,9 @@ function buildButtonContainer(bundleId, elements) {
36
43
  if (customLabel) {
37
44
  btn.textContent = customLabel;
38
45
  } else if (elements.length === 1 && !forceZip) {
39
- const filename = elements[0].getAttribute('data-zip-filename') || 'file';
40
- const displayName = filename.split('/').pop(); // basename
46
+ const rawFilename = elements[0].getAttribute('data-zip-filename');
47
+ const filename = sanitizeFilename(rawFilename || '') || 'file';
48
+ const displayName = filename.split('/').pop() || 'file'; // basename
41
49
  btn.textContent = `Download ${displayName}`;
42
50
  } else {
43
51
  const label = titleCase(bundleId.replace(/-/g, ' '));
@@ -88,6 +96,14 @@ if (window.document$ && typeof window.document$.subscribe === 'function') {
88
96
 
89
97
  // ── Download handling ─────────────────────────────────────────────────
90
98
 
99
+ function extractCodeContent(el) {
100
+ const codeEl = el.querySelector('code') || el;
101
+ const clone = codeEl.cloneNode(true);
102
+ // Strip line numbers, Material code annotations, and clipboard buttons
103
+ clone.querySelectorAll('.linenos, .lineno, .md-annotation, .md-clipboard').forEach(n => n.remove());
104
+ return clone.textContent;
105
+ }
106
+
91
107
  document.addEventListener('click', function(e) {
92
108
  const btn = e.target.closest('.zip-bundle-btn[data-bundle-id]');
93
109
  if (btn) {
@@ -96,7 +112,8 @@ document.addEventListener('click', function(e) {
96
112
  });
97
113
 
98
114
  async function downloadZipBundle(bundleId) {
99
- const elements = document.querySelectorAll(`[data-zip-bundle="${bundleId}"]`);
115
+ const escapedId = (window.CSS && CSS.escape) ? CSS.escape(bundleId) : bundleId.replace(/"/g, '\\"');
116
+ const elements = document.querySelectorAll(`[data-zip-bundle="${escapedId}"]`);
100
117
 
101
118
  if (elements.length === 0) {
102
119
  console.warn(`No elements found for bundle ID: ${bundleId}`);
@@ -108,9 +125,9 @@ async function downloadZipBundle(bundleId) {
108
125
  // If it's only one file AND we aren't forcing a ZIP, download it directly
109
126
  if (elements.length === 1 && !forceZip) {
110
127
  const el = elements[0];
111
- const filename = el.getAttribute('data-zip-filename') || 'file';
112
- const codeEl = el.querySelector('code') || el;
113
- const content = codeEl.innerText;
128
+ const rawFilename = el.getAttribute('data-zip-filename');
129
+ const filename = sanitizeFilename(rawFilename || '') || 'file';
130
+ const content = extractCodeContent(el);
114
131
 
115
132
  // Ensure UTF-8 encoding
116
133
  const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
@@ -128,20 +145,18 @@ async function downloadZipBundle(bundleId) {
128
145
 
129
146
  const zip = new JSZip();
130
147
  elements.forEach(el => {
131
- const filename = el.getAttribute('data-zip-filename') || 'unnamed-file';
132
- const codeEl = el.querySelector('code') || el;
133
- const content = codeEl.innerText;
134
-
135
- // Skip empty files if they have no content
136
- if (content.trim().length === 0) {
137
- console.warn(`Skipping empty file: ${filename}`);
138
- return;
139
- }
148
+ const rawFilename = el.getAttribute('data-zip-filename');
149
+ const filename = sanitizeFilename(rawFilename || '') || 'unnamed-file';
150
+ const content = extractCodeContent(el);
140
151
 
141
- zip.file(filename, content);
152
+ zip.file(filename, content, { date: new Date(0) });
142
153
  });
143
154
 
144
- const blob = await zip.generateAsync({type: "blob"});
155
+ const blob = await zip.generateAsync({
156
+ type: "blob",
157
+ compression: "DEFLATE",
158
+ compressionOptions: { level: 6 }
159
+ });
145
160
  const url = URL.createObjectURL(blob);
146
161
  triggerDownload(url, `${bundleId}.zip`);
147
162
  }
@@ -43,15 +43,22 @@ class ZipBundlePlugin(BasePlugin):
43
43
  """
44
44
  Sanitize filename to prevent path traversal while allowing subdirectories.
45
45
  """
46
- # Replace backslashes with forward slashes for ZIP compatibility
47
- filename = filename.replace('\\', '/')
48
- # Remove any ".." segments to prevent traversal
49
- filename = re.sub(r'\.\.(?=/|$)', '', filename)
50
- # Remove any leading slashes
51
- filename = re.sub(r'^/+', '', filename)
52
- # Collapse multiple slashes
53
- filename = re.sub(r'/+', '/', filename)
54
- return filename.strip()
46
+ if not filename:
47
+ return ""
48
+ # Remove null bytes and normalize slashes
49
+ filename = filename.replace('\0', '').replace('\\', '/')
50
+ # Split into path components and clean each segment
51
+ parts = []
52
+ for part in filename.split('/'):
53
+ part = part.strip()
54
+ # Drop empty segments and any segment consisting only of dots (., .., ...)
55
+ if not part or re.fullmatch(r'\.+', part):
56
+ continue
57
+ # Strip Windows drive letters if leading (e.g. 'C:')
58
+ if len(part) == 2 and part[1] == ':' and part[0].isalpha():
59
+ continue
60
+ parts.append(part)
61
+ return '/'.join(parts)
55
62
 
56
63
  def _create_button(self, soup, bundle_id, elements):
57
64
  """
@@ -70,9 +77,9 @@ class ZipBundlePlugin(BasePlugin):
70
77
  if custom_label:
71
78
  btn.string = custom_label
72
79
  elif len(elements) == 1 and not force_zip:
73
- filename = elements[0].get('data-zip-filename', 'file')
80
+ filename = elements[0].get('data-zip-filename') or 'file'
74
81
  # Extract only the base filename for the button label if it's a path
75
- display_name = os.path.basename(filename)
82
+ display_name = os.path.basename(filename) or 'file'
76
83
  btn.string = f"Download {display_name}"
77
84
  else:
78
85
  label = bundle_id.replace('-', ' ').title()
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-zip-bundle-plugin
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical.
5
5
  Author: Daemonless
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
8
  Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
9
  Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mkdocs-zip-bundle-plugin"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Daemonless" }]
11
- license = { text = "MIT" }
11
+ license = "MIT"
12
12
  requires-python = ">=3.8"
13
13
  keywords = ["mkdocs", "material", "zensical", "zip", "bundle", "placeholders", "interactive"]
14
14
  dependencies = [
@@ -34,7 +34,20 @@ def test_sanitize_filename(plugin):
34
34
  assert plugin._sanitize_filename('some/../../path.txt') == 'some/path.txt'
35
35
  assert plugin._sanitize_filename('win\\path.txt') == 'win/path.txt'
36
36
  assert plugin._sanitize_filename('subdir/file.txt') == 'subdir/file.txt'
37
- assert plugin._sanitize_filename('//double//slash//') == 'double/slash/'
37
+ assert plugin._sanitize_filename('//double//slash//') == 'double/slash'
38
+ # Traversal bypass attempts
39
+ assert plugin._sanitize_filename('....//test.txt') == 'test.txt'
40
+ assert plugin._sanitize_filename('....//....//etc/passwd') == 'etc/passwd'
41
+ assert plugin._sanitize_filename('a/b/../../../c') == 'a/b/c'
42
+ assert plugin._sanitize_filename('C:\\Windows\\System32\\cmd.exe') == 'Windows/System32/cmd.exe'
43
+ assert plugin._sanitize_filename('foo/./bar') == 'foo/bar'
44
+ assert plugin._sanitize_filename('\0secret.txt') == 'secret.txt'
45
+ # Empty and dot-only inputs
46
+ assert plugin._sanitize_filename(' ') == ''
47
+ assert plugin._sanitize_filename('') == ''
48
+ assert plugin._sanitize_filename('..') == ''
49
+ assert plugin._sanitize_filename('...') == ''
50
+ assert plugin._sanitize_filename(None) == ''
38
51
 
39
52
 
40
53
  # --- _create_button ---
@@ -50,6 +63,16 @@ def test_create_button_single(plugin):
50
63
  assert 'onclick' not in button.attrs
51
64
 
52
65
 
66
+ def test_create_button_single_empty_filename(plugin):
67
+ """Empty or traversal-only filename should fall back safely to 'Download file'."""
68
+ soup = BeautifulSoup('', 'html.parser')
69
+ elements = [make_element(soup, {'data-zip-filename': ''})]
70
+ container = plugin._create_button(soup, 'my-bundle', elements)
71
+
72
+ button = container.find('button')
73
+ assert button.string == 'Download file'
74
+
75
+
53
76
  def test_create_button_single_subdir(plugin):
54
77
  soup = BeautifulSoup('', 'html.parser')
55
78
  elements = [make_element(soup, {'data-zip-filename': 'configs/compose.yaml'})]