Skip to content

File Manager: Add Compress & Extract archive support#2630

Draft
mgutt wants to merge 173 commits into
unraid:masterfrom
mgutt:fix-issue-2511
Draft

File Manager: Add Compress & Extract archive support#2630
mgutt wants to merge 173 commits into
unraid:masterfrom
mgutt:fix-issue-2511

Conversation

@mgutt
Copy link
Copy Markdown
Contributor

@mgutt mgutt commented May 6, 2026

Closes #2511

Summary

Adds compress and extract functionality to the Unraid File Manager.

What's new

Compress

  • New Compress button in the toolbar and three-dot menu
  • Format selector: ZIP, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, TAR.ZST, TAR.LZ4; additionally .gz, .xz, .bz2, .zst, .lz4 when exactly one file (no folder) is selected
  • Smart default format: reads /boot/config/docker.cfg to detect the user's appdata/docker paths - selects TAR.ZST for those paths (ideal for container backups), ZIP for everything else
  • Custom archive name (pre-filled from selection, auto-incremented if already exists)
  • Destination picker with folder tree and popular destinations
  • Bulk compress: select multiple files/folders, all are packed into one archive
  • Overwrite protection: checkbox (default: off) — skips the already-exists check when enabled
  • Progress display: %, speed, ETA, current filename; single-file formats (.gz etc.) show "Running..." only — no byte-level progress (tool writes directly to output file)
image

Extract

  • New Extract button in toolbar (enabled only when exactly one archive is selected) and three-dot menu
  • Auto-detects format from extension: .zip, .tar, .tar.gz / .tgz, .tar.bz2 / .tbz2, .tar.xz / .txz, .tar.zst, .tar.lz4, .gz, .bz2, .xz, .zst, .lz4
  • Destination picker with folder tree and popular destinations
  • Overwrite protection: checkbox (default: off); ZIP uses -n/-o, TAR uses --skip-old-files --warning=no-existing-file; single-file formats abort with "File already exists" error when overwrite is off
  • Progress display: %, speed, ETA, current filename; single-file formats (.gz etc.) show "Running..." only — no byte-level progress
  • Clicking an archive file in the file browser now triggers a direct download instead of opening an unsupported viewer

Permissions

Created archives and new target directories get the following permissions:

  • Shares (default): chown nobody:users, chmod 666 - accessible via SMB
  • Docker paths (appdata, docker): chown root:root, chmod 600 - prevents accidental exposure via container webservers
  • New directories created when user types a new target path: 777 nobody:users for shares, 755 root:root for docker paths
  • Docker path detection strips the /mnt/<disk>/ prefix so it correctly matches on any disk or pool (e.g. /mnt/user/appdata, /mnt/disk1/appdata, /mnt/cache/appdata all match)

Progress implementation

All progress parsing is handled by a dedicated progress script (scripts/progress) that is piped directly from the compress/extract command. It detects the running tool (zip/tar/unzip) by inspecting /proc, parses its stdout, and emits a structured log to the status file:

TOTAL <bytes>
FILE  <timestamp> <size> <filename>
CHUNK <timestamp> <bytes>

The PHP worker reads this file incrementally every 250 ms and derives %, speed, and ETA from it. CHUNK lines carry sub-second timestamps ($EPOCHREALTIME) for accurate speed calculation - integer-second timestamps would cause speed to snap to discrete multiples (e.g. 100 MB/1s, 100 MB/2s).

ZIP compress: zip -r -du -dd -ds 100m emits a (size) suffix per file followed by one dot per 100 MB. The progress script parses stdout char-by-char: detects the (size) suffix to emit a FILE line, then counts dots to emit CHUNK lines. TOTAL is pre-calculated with du -sb from the source file list in the zip command.

ZIP extract: unzip only prints a filename after each file is fully written, so byte-level progress cannot come from stdout. Instead, the progress script reads the manifest from unzip -l upfront, then polls each output file with stat every 0.2s to emit CHUNK lines proportional to growing file size. The script reads unzip stdout char-by-char with a 100ms read timeout: on timeout (file is being written) it starts polling; on newline (small file finished before timeout) it emits a FILE line directly. TOTAL is the sum of all file sizes from the manifest.

TAR compress: uses --checkpoint=N --checkpoint-action=exec="printf 'CHUNK\n'" (1 checkpoint = 100 MB). The progress script parses tar's verbose filename output for FILE lines and the CHUNK sentinel lines for progress. TOTAL is pre-calculated with du -sb from the source file list.

TAR extract: same checkpoint mechanism for CHUNK lines. TOTAL is obtained by running tar -tvf with a 5-second timeout before the main extract starts - instant for uncompressed archives, usually finishes within the timeout for compressed ones. If the timeout is hit, progress runs without a total (shows bytes transferred + speed only, switches to % automatically once TOTAL arrives).

Single-file formats (.gz, .bz2, .xz, .zst, .lz4): no stdout-based progress available - the decompressor writes directly to the output file. The UI shows "Running..." for the duration and "Done" on completion.

Security

  • Path traversal prevention: archive_name is validated server-side (basename() + empty/dot check) to prevent writing outside the target directory
  • Target path validation: compress and extract target paths are validated against allowed roots (/mnt, /boot) server-side - same as all other file manager operations
  • Shell injection prevention: all user-supplied paths in compress/extract shell commands are passed through escapeshellarg(); heredoc interpolation was replaced with pre-escaped variables

Other

  • Compress and extract target paths are tracked in popular destinations (filemanager.json) - same as copy/move
  • Cancel during compress and single-file extract cleans up the partial .tmp file reliably
  • Temporary file uses a uniqid-based name with collision-check loop, stored in the active job JSON so cancel can locate it even after a page reload
  • When a new action starts, the nchan channel is immediately cleared to prevent the client briefly seeing the previous operation's final status (nchan buffers last message for 30s)
  • ZIP uses LANG=en_US.UTF-8 for both compress and extract - Unraid runs with POSIX locale which mangles non-ASCII filenames (e.g. Chinese, Arabic) as #Uxxxx escape sequences without this
  • ZIP compress uses -y to store symlinks as symlinks rather than skipping them (broken symlinks and cross-device symlinks were silently dropped without this)
  • TAR compress uses --xattrs --xattrs-include='*.*' --acls --sparse: preserves extended attributes (all namespaces: user.*, security.*, trusted.*), POSIX ACLs, and sparse-file holes (important for VM images, container overlay filesystems, and databases)
  • TAR extract additionally uses --numeric-owner: restores exact uid/gid numbers instead of doing a name-based lookup (critical when restoring to a system where numeric IDs differ from the archive)

Implementation status

  • Project started at 2026-02-08
  • Frontend (Browse.page): context menus + dialog templates
  • Backend (Control.php): job queue handling for compress/extract
  • Worker (file_manager): compression/extraction logic
  • Bug fixes: parameter extraction, selector scope, bulk operations
  • Progress display (%, speed, ETA, filename)
  • Tested formats
    • compress zip
    • compress tar
    • compress tar.gz
    • compress tar.bz2
    • compress tar.xz
    • compress tar.zst
    • compress tar.lz4
  • extract zip (progress, %, speed, ETA, filename; ^J newline in filenames handled; overwrite flag -n/-o)
    • extract tar.gz / .tgz
    • extract tar.bz2 / .tbz2
    • extract tar.xz / .txz
    • extract tar.zst
    • extract tar.lz4
    • extract tar
  • Edge case testing
    • Hardlinks: preserved in tar (both hardlinked files share the same inode after extraction); zip always breaks hardlinks - expected behavior
    • Symlinks: ZIP uses -y → stored as symlinks (broken + cross-device symlinks preserved); TAR stores symlinks by default
    • Special chars in filenames: spaces, quotes, $, newlines - tested with real-world data
    • Non-ASCII filenames (Chinese, Arabic, etc.): LANG=en_US.UTF-8 on zip/unzip prevents #Uxxxx mangling
    • xattrs/ACLs preserved on extract (tar: --xattrs --acls; tested with user.author, user.comment)
    • Empty directories: zip -r includes directory entries → empty dirs preserved; verified with diff -rq (no differences)
    • Very large archives (>4GB): zip64 auto-enabled and verified - tested with a 4.1 GB archive (single 4 GB file) and a 97 GB archive; both contain the Zip64 EOCD locator
    • Concurrent compress to same target dir: file_manager runs a single worker loop (one action at a time); tmp name uses uniqid('', true) with a collision-check loop as an additional safeguard
  • Permissions
    • Shares (default): chown nobody:users, chmod 666 - accessible via SMB
    • Docker paths (appdata/docker): chown root:root, chmod 600 - prevents accidental exposure via container webservers
    • New directories created when user types a new target path: 777 nobody:users for shares, 755 root:root for docker paths
    • Detection strips /mnt/<disk>/ prefix so it matches correctly on any disk or pool (same logic as the format auto-detection in Browse.page)
  • Popular destinations tracked for compress/extract (same as copy/move)
  • Cancel cleans up partial .tmp file
  • Clicking an archive in the file browser triggers direct download (not unsupported viewer)
  • Overwrite existing archive option in Compress dialog: skips the archive-already-exists check when enabled
  • 🙅 "Store absolute paths" option: deliberately not implemented - absolute paths in archives are dangerous for non-technical users (extracting an archive with /mnt/disk4/... paths could silently overwrite data on a different disk if the disk layout changed); relative paths (the default tar/zip behavior) are always safe and sufficient
  • Single-file compress formats (.gz, .xz, .bz2, .zst, .lz4) — only shown in the format dropdown when exactly one file (no folder) is selected; no progress % yet (compressor writes directly to output file, no stdout-based progress)
    • progress via pv or stat-polling (to be implemented)
    • compress .gz / .xz / .bz2 / .zst / .lz4 — magic bytes verified for all formats
    • extract .gz / .xz / .bz2 / .zst / .lz4 — MD5 matches original for all formats
    • overwrite=off: "File already exists" error shown; overwrite=on: file replaced correctly
    • cancel during extract: no partial/tmp file left in destination
    • special characters in filename (spaces, parentheses, dots) handled correctly
    • compress into same directory as source: "already exists" error when dest would collide
    • extract into same directory as source: works correctly
  • Settings page to configure which formats are shown in the compress dialog
  • 🚫 7z and rar support - would require bundling the binaries
  • Sources disappear in dialog after minimize/reopen during active operation - probably fixed, not yet confirmed
  • Debug logger cleanup (before merge)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Compress and Extract functionality supporting multiple archive formats: zip, tar.gz, tar.bz2, tar.xz, tar.zst, and tar.lz4.
    • Smart archive format auto-selection: tar.zst for Docker/appdata paths, zip by default elsewhere.
  • Improvements

    • Enhanced progress reporting for compression and extraction operations.
    • Improved safety checks for file operations including better rename/move validation and archive overwrite handling.

Review Change Stack

mgutt added 30 commits May 3, 2026 16:53
- Add Compress option to folder and file context menus
- Add Extract option to archive files (zip, tar, gz, etc.)
- Create compress/extract dialog templates in Templates.php
- Implement doAction cases 16 (compress) and 17 (extract)
- Add auto-detection for archive format based on path (zstd for appdata, zip for shares)
- Add updateArchiveName() helper to sync archive name with format selection
- Add validation for compress/extract actions in Start button handler
…lementation

- Control.php: Add format and archive_name parameters for compress action
- file_manager: Implement case 16 (compress) with support for zip, tar, tar.gz, tar.zst
- file_manager: Implement case 17 (extract) with auto-detection of archive type
- Support for multiple archive formats: zip, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z, rar
- Add progress tracking via tee to status file
- Add warning texts for compress/extract actions in Browse.page
- Implement parse_zip_progress() to parse structured log format
- Create zip_wrapper script for real-time zip progress parsing
- Integrate zip_wrapper into compress case 16
- Parse DOT|timestamp entries to calculate speed and ETA
- Display progress: Completed: X%, Archive: YMB, Speed: ZMB/s, ETA: H:MM:SS
- Keep tar formats using simple status display for now
- Remove unused $format parameter from zip_wrapper
- Fix shellcheck redirect warning (use : > instead of >)
- Use pipe-safe format: FILE|size|filename (filename at end)
- Add proper PHPDoc comment for parse_zip_progress
- Update PHP parser to read filename from third field
- Change from pipeline to direct redirect
- Avoids PID mismatch (pipeline returns wrong PID)
- zip_wrapper logs to /var/tmp/zip.progress independently
…ps for speed calculation, fix regex to match end of line
mgutt added 27 commits May 14, 2026 14:29
…plate, integrated overwrite sub-tests, simplified sentinel detection
…checks

file_manager:
- add FM_DEBUG_FORCE_COPY_DELETE constant: forces rsync copy-delete path
  when /var/tmp/file.manager.force-copy-delete.debug exists (requires
  FM_DEBUG_MODE); used by test to exercise both move code paths
- fix fm_delete_file(): only rename to .debug in debug mode, otherwise
  call delete_file() as before (was always renaming unconditionally)
- apply FM_DEBUG_FORCE_COPY_DELETE override in move action (case 4/9)

test-file-manager.sh:
- add dir_fingerprint(): find-based metadata fingerprint (type, perms,
  mtime, size, symlink target); optional arg includes link count (%n)
  for hardlink verification
- add fingerprint_match(): convenience wrapper for fingerprint comparison
- add test_copy() / test_copy_file() / test_copy_folder(): tests copy
  actions 8 (file) and 3 (folder); checks source survives, destination
  exists, fingerprint matches
- add test_move() / test_move_file() / test_move_folder(): tests move
  actions 9 (file) and 4 (folder) for both rsync-rename and copy-delete
  paths; pre-move fingerprint compared to post-move destination;
  hardlink count included in fingerprint only for rsync-rename folder
  moves (inodes preserved); last folder-move restores src_path
- add dedicated source files for copy and move tests
- add copy test calls (special name file + folder)
- add move test calls (file rsync-rename, file copy-delete,
  folder rsync-rename, folder copy-delete)
rsync --backup-dir creates target directories with mkdir, which sets
their mtime to the current time instead of preserving the original.

Fix: record all dir mtimes (nanosecond precision via find -printf '%T@')
into a bash associative array before the rsync-rename, then restore each
dir mtime in the target afterwards using touch -d "@mtime".

Also: copy empty dirs step now runs as step 1 (not affected by mtime
issue since step 2 would overwrite them anyway).

Also: add test case for special chars with backslash-asterisk in filename.
Backslash must be escaped: a filename like \* needs pattern \\*
(escaped-backslash + escaped-asterisk) so rsync matches it literally.
Without escaping \, the pattern \* would silently consume the backslash.
Also: check link count (%n) for ALL folder moves (action 4),
not just rsync-rename - copy-delete must also preserve hardlinks.
…les and byte-for-byte diff

- replace tmp file snapshots with variable capture: pre=$(find_metadata ...)
- pipe diff output through head -20 | sed 's/^/  /' for readable output
- use PIPESTATUS[0] to capture diff rc through the pipeline
- replace all byte-for-byte diff -r checks with find_metadata comparisons
- add zip-aware metadata fmt (omit %n since zip does not preserve hardlinks)
- find_metadata accepts custom fmt as second arg (default includes %n)
- fix run_action tail loop to also break on done:2 (used by search action)
file_manager:
- fix zip extract: restore symlink mtimes via ZipArchive +
  touch -h after unzip (upstream bug since 2007: unzip never calls
  lutimes() for symlinks; identify via S_IFLNK=0xA000 in external attrs)
- add fm_debug_log() helper; gate all 28 unconditional logger calls,
  file_put_contents debug writes and temp file copies behind FM_DEBUG_MODE

test-file-manager.sh:
- rename JOB_TIMEOUT to TIMEOUT; add MULTI/SINGLE ENV overrides for
  archive format arrays; move arrays from bottom to settings section
- add help block with usage, filter docs, ENV overrides, examples
- forward TIMEOUT/MULTI/SINGLE to remote host via SSH (printf %q)
- rewrite should_run(): accept multiple keywords; add word-boundary
  matching for numeric IDs to prevent e.g. 1 matching 16
- expand all should_run() calls with full keyword sets
- find_metadata(): move %P first for stable sort before sed zeroing;
  add extra predicate passthrough via ${@:3}
- test_extract(): single-file formats compare type+size only (no mtime);
  zip: single-pass diff with DOS-time rounding via sed; fix symlink
  mtime check now possible since file_manager restores it
- remove | head -20 from diff output in rename/copy/move checks
- test_chown(): action 11 test; verifies owner via stat -c '%U:%G'
- test_search(): action 15 test; verifies results array in nchan done
  output; checks expected_match in results[].path if given
- add fixtures: chown test file
- add test sections: chown x2 (nobody:users/root:root),
  search x3 (exact name, wildcard *.txt, no-match pattern)
- update help block with chown/search filter keywords
…hing

normalize non-keyword chars (commas, semicolons, etc.) to spaces so
both 'arc search mv' and 'arc,search,mv' work as filter input;
prevents partial matches like 'arc' hitting 'search'
- count expected results with find -print0 | tr -cd '\0' | wc -c
  so filenames with newlines are counted correctly (1 per file)
- compare against results | length: detects when file_manager search
  splits newline filenames into multiple results
- check all result paths start with search_dir: catches split
  filenames that appear without directory prefix (location '---')
- remove expected_match parameter: count + path checks are sufficient
- fix: use [[ $done_line ]] instead of arithmetic expansion on JSON string
find outputs paths line-by-line; filenames containing newlines were
split across two lines, appearing as two separate search results.
fixes:
- find: add -print0 (null-terminated output)
- cat(): read with explode('\0') instead of file() to parse
  null-terminated paths
- progress count: use substr_count(..., '\0') instead of wc -l
jq -r outputs paths with embedded newlines as multi-line text;
grep then sees the second part as a separate string without the
directory prefix, causing a false FAIL.

Fix: validate paths entirely within jq using startswith() so
embedded newlines are handled transparently. Use @JSON encoding
for the diagnostic output on failure.
leaves /mnt/disk1/fm_test/dst intact when tests fail so created
files can be inspected without having to comment out cleanup
- all source file setup is now in create_source_files(); idempotent
- hardlink check: verify both files share the same inode; if not,
  delete hardlink2 and re-create it to restore the hardlink
- after each folder-move test: silently move src back if needed,
  then call create_source_files so each test starts with clean src
FM_DEBUG_FORCE_COPY_DELETE is a PHP define() evaluated at process
start - touching the force file while file_manager is already
running had no effect. restart file_manager after creating the
force file so the new process picks up the flag.
rename: rename, rn, 2, 7  (actions 2, 7)
move:   move, mv, 4, 9    (actions 4, 9)
…urce_files

- separate filters: compress/cmp/arc/16 vs extract/arc/17
- src/files/ for all source files (was src/)
- src/archives/ for pre-built archives used by extract tests
- create_source_files builds all archives idempotently
- extract tests no longer depend on compress tests having run first
- add build_archive(): native tools (tar/zip/gz/bz2/xz/zst/lz4), atomic
  creation via .tmp + rename, tar --sparse/--xattrs/--acls matching
  file_manager; tar.zst/tar.lz4 via pipe to avoid unrecognized flags
- replace run_action archive building with build_archive() in
  create_source_files(); keeps simple [[ ! -f ]] guard
- fix test_extract: default source_dir was $src_path, now $files_path
- fix inode comparison: quote $inode2 (shellcheck SC2053)
- normalize all mtimes to 202001010000 before/after archive builds so
  extract diffs are reproducible even after delete/rename tests recreate
  source files; touch -h to handle symlinks without following targets
- add create_source_files after delete and rename test sections to repair
  source files before subsequent tests
- fix move restore logic: $dst_path/src -> $dst_path/files
- replace vague 'special name' labels with 'special chars' (files) and
  'files dir' (folder tests on $files_path)
- remove parse_compress_progress() - fully superseded by parse_pv_progress()
- remove PROGRESS_SCRIPT - defined but never used
- remove TAR_RECORD_SIZE and TAR_CHECKPOINT - only used to define each other,
  never referenced elsewhere
- remove two reset calls to parse_compress_progress() in case 16 and 17
@mgutt
Copy link
Copy Markdown
Contributor Author

mgutt commented May 17, 2026

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/scripts/test-file-manager.sh (1)

497-497: 💤 Low value

Add local declaration for extracted_subdir.

This variable is assigned without local, causing it to leak into global scope. While this won't affect test correctness, it's inconsistent with other local variables in this function.

📝 Proposed fix
-  extracted_subdir=$(find "$dest" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' -quit)
+  local extracted_subdir
+  extracted_subdir=$(find "$dest" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' -quit)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/test-file-manager.sh at line 497, The variable
extracted_subdir is assigned without a local declaration and should be made
local to avoid leaking into the global scope; update the function that contains
the find assignment (where other local variables are declared) to change the
assignment to declare extracted_subdir as local (i.e., add a local declaration
for extracted_subdir before or on the same line as the find assignment) so it
follows the same scoping pattern as the other local variables in that function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@emhttp/plugins/dynamix/nchan/file_manager`:
- Around line 1178-1207: The docker-path detection (parsing
/boot/config/docker.cfg into $docker_subpaths and computing $is_docker_dest via
preg_match and str_starts_with against $dest) must be moved/duplicated so it
runs before the code paths that handle single-file archive extraction into an
existing directory; currently it's only inside the if (!is_dir($dest)) block
causing $is_docker_dest to be undefined. Extract the detection into a small
pre-check executed unconditionally (or initialize $is_docker_dest = false before
use), reuse the same logic that reads DOCKER_APP_CONFIG_PATH/DOCKER_IMAGE_FILE
with parse_ini_file and compares against $dest, and then use that computed
$is_docker_dest when calling exec("mkdir -pm0...") and exec("chown -R...") (and
apply the same fix to the similar block that handles compress/case 16). Ensure
you reference and reuse the existing helpers/variables ($dest, $docker_cfg,
$docker_subpaths, $is_docker_dest, preg_match, str_starts_with, quoted) so
behavior stays identical.

---

Nitpick comments:
In @.github/scripts/test-file-manager.sh:
- Line 497: The variable extracted_subdir is assigned without a local
declaration and should be made local to avoid leaking into the global scope;
update the function that contains the find assignment (where other local
variables are declared) to change the assignment to declare extracted_subdir as
local (i.e., add a local declaration for extracted_subdir before or on the same
line as the find assignment) so it follows the same scoping pattern as the other
local variables in that function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ccfae75d-fb20-4252-82d4-4f7302d86c35

📥 Commits

Reviewing files that changed from the base of the PR and between 7f09419 and 5867271.

📒 Files selected for processing (8)
  • .github/scripts/test-file-manager.sh
  • emhttp/plugins/dynamix/Browse.page
  • emhttp/plugins/dynamix/BrowseButton.page
  • emhttp/plugins/dynamix/include/Control.php
  • emhttp/plugins/dynamix/include/Templates.php
  • emhttp/plugins/dynamix/nchan/file_manager
  • emhttp/plugins/dynamix/scripts/pvzip
  • emhttp/plugins/dynamix/sheets/BrowseButton.css
🚧 Files skipped from review as they are similar to previous changes (4)
  • emhttp/plugins/dynamix/sheets/BrowseButton.css
  • emhttp/plugins/dynamix/include/Control.php
  • emhttp/plugins/dynamix/include/Templates.php
  • emhttp/plugins/dynamix/Browse.page

Comment on lines +1178 to +1207
// Create dest with proper permissions if it doesn't exist.
// Docker paths get root:root 755, all other shares get nobody:users 777.
if (!is_dir($dest)) {
$docker_cfg = @parse_ini_file('/boot/config/docker.cfg') ?: [];
$docker_subpaths = [];
foreach (['DOCKER_APP_CONFIG_PATH', 'DOCKER_IMAGE_FILE'] as $key) {
$val = rtrim(trim($docker_cfg[$key] ?? ''), '/');
if ($val && preg_match('#^/mnt/[^/]+/(.+)$#', $val, $m)) {
$docker_subpaths[] = $m[1];
}
}
if (empty($docker_subpaths)) $docker_subpaths = ['appdata', 'docker'];
$is_docker_dest = false;
if (preg_match('#^/mnt/[^/]+/(.+)$#', $dest, $m)) {
foreach ($docker_subpaths as $dp) {
if ($m[1] === $dp || str_starts_with($m[1], $dp.'/')) {
$is_docker_dest = true;
break;
}
}
}
$new_root = $dest;
$check = $dest;
while ($check !== '/' && !is_dir($check)) {
$new_root = $check;
$check = dirname($check);
}
exec("mkdir -pm0".($is_docker_dest ? '755' : '777')." ".quoted($dest));
if (!$is_docker_dest) exec("chown -R nobody:users ".quoted($new_root));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

$is_docker_dest undefined when extracting single-file archives to an existing directory.

The docker-path detection at lines 1180-1207 only runs inside if (!is_dir($dest)). When extracting a single-file compressed archive (gz/bz2/xz/zst/lz4) to an existing directory, $is_docker_dest is never set, causing a PHP warning and incorrect permissions (always defaults to nobody:users 666 even for docker paths).

Compare to compress (case 16) at lines 921-930 which correctly computes $is_docker_path unconditionally before using it.

🐛 Suggested fix: Move docker detection before the mkdir block
         // Pre-escape user-controlled paths for safe interpolation
         $esc_archive = escapeshellarg($archive);
         $esc_dest    = escapeshellarg($dest);

         fm_debug_log('extract start: archive=' . $archive . ', dest=' . $dest);
         fm_debug_log('extract start: quoted_archive=' . quoted($archive) . ', quoted_dest=' . quoted($dest));

-        // Create dest with proper permissions if it doesn't exist.
-        // Docker paths get root:root 755, all other shares get nobody:users 777.
-        if (!is_dir($dest)) {
-          $docker_cfg = `@parse_ini_file`('/boot/config/docker.cfg') ?: [];
-          $docker_subpaths = [];
-          foreach (['DOCKER_APP_CONFIG_PATH', 'DOCKER_IMAGE_FILE'] as $key) {
-            $val = rtrim(trim($docker_cfg[$key] ?? ''), '/');
-            if ($val && preg_match('#^/mnt/[^/]+/(.+)$#', $val, $m)) {
-              $docker_subpaths[] = $m[1];
-            }
+        // Detect docker path for permission handling (needed even if dest exists)
+        $docker_cfg = `@parse_ini_file`('/boot/config/docker.cfg') ?: [];
+        $docker_subpaths = [];
+        foreach (['DOCKER_APP_CONFIG_PATH', 'DOCKER_IMAGE_FILE'] as $key) {
+          $val = rtrim(trim($docker_cfg[$key] ?? ''), '/');
+          if ($val && preg_match('#^/mnt/[^/]+/(.+)$#', $val, $m)) {
+            $docker_subpaths[] = $m[1];
           }
-          if (empty($docker_subpaths)) $docker_subpaths = ['appdata', 'docker'];
-          $is_docker_dest = false;
-          if (preg_match('#^/mnt/[^/]+/(.+)$#', $dest, $m)) {
-            foreach ($docker_subpaths as $dp) {
-              if ($m[1] === $dp || str_starts_with($m[1], $dp.'/')) {
-                $is_docker_dest = true;
-                break;
-              }
+        }
+        if (empty($docker_subpaths)) $docker_subpaths = ['appdata', 'docker'];
+        $is_docker_dest = false;
+        if (preg_match('#^/mnt/[^/]+/(.+)$#', $dest, $m)) {
+          foreach ($docker_subpaths as $dp) {
+            if ($m[1] === $dp || str_starts_with($m[1], $dp.'/')) {
+              $is_docker_dest = true;
+              break;
             }
           }
+        }
+
+        // Create dest with proper permissions if it doesn't exist.
+        // Docker paths get root:root 755, all other shares get nobody:users 777.
+        if (!is_dir($dest)) {
           $new_root = $dest;
           $check = $dest;

Also applies to: 1243-1264

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@emhttp/plugins/dynamix/nchan/file_manager` around lines 1178 - 1207, The
docker-path detection (parsing /boot/config/docker.cfg into $docker_subpaths and
computing $is_docker_dest via preg_match and str_starts_with against $dest) must
be moved/duplicated so it runs before the code paths that handle single-file
archive extraction into an existing directory; currently it's only inside the if
(!is_dir($dest)) block causing $is_docker_dest to be undefined. Extract the
detection into a small pre-check executed unconditionally (or initialize
$is_docker_dest = false before use), reuse the same logic that reads
DOCKER_APP_CONFIG_PATH/DOCKER_IMAGE_FILE with parse_ini_file and compares
against $dest, and then use that computed $is_docker_dest when calling
exec("mkdir -pm0...") and exec("chown -R...") (and apply the same fix to the
similar block that handles compress/case 16). Ensure you reference and reuse the
existing helpers/variables ($dest, $docker_cfg, $docker_subpaths,
$is_docker_dest, preg_match, str_starts_with, quoted) so behavior stays
identical.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] File Manager: Add Compress/Extract archive

1 participant