### 2026.11.13.3
New Large window button in the plugin header opens the whole plugin in a large window over the page; Escape or Close window returns. The pool warning bar is now one compact line with a shorter replication message.
ZFS Director: based on ZFS Master by Carlos Rodriguez (IkerSaint). GPL-2.0-or-later. Settings and recovery state retain their namespace. Background services start automatically after successful validation. Uninstall stops Director services and preserves settings, Jobs and recovery data.
set -euo pipefail
# Plugin Manager displays stdout only; keep actionable errors in its dialog.
exec 2>&1
trap 'status=$?; echo "ZFS Director was NOT updated (exit $status). Nothing was changed: your settings, schedules and data are intact. The reason is shown above."; exit "$status"' ERR
umask 077
archive=/boot/config/plugins/zfs.master.ui/zfs-director-2026.11.13.3.tgz
live=/usr/local/emhttp/plugins/zfs.master.ui
stage=/usr/local/emhttp/plugins/.zfs-director-stage-2026.11.13.3-$$
previous=/usr/local/emhttp/plugins/.zfs-director-previous-2026.11.13.3-$$
# Legacy migration guard. Inspect now; cleanup runs after package validation.
registration=zfs-director.plg
legacy_backup=/boot/config/plugins-removed/zfs-director-2026.11.13.3-$(date -u +%Y%m%dT%H%M%SZ)-$$
legacy_cleanup() {
/usr/bin/php -r '
// Embedded in the PLG: only known plugin code/registrations are replaced.
// Director settings, journals and recovery never enter the cleanup list.
function zfsdLegacyPath(string $path,bool $registration=false): void {
if(!str_starts_with($path,'"'"'/'"'"')||preg_match('"'"'~(?:^|/)\.\.?(/|$)~'"'"',$path))throw new RuntimeException('"'"'Unsafe installer path'"'"');
for($parent=dirname($path);$parent!=='"'"'/'"'"';$parent=dirname($parent)){
if(is_link($parent)||(file_exists($parent)&&!is_dir($parent)))throw new RuntimeException('"'"'Unsafe installer parent: '"'"'.$parent);
}
if(is_link($path)){
if(!$registration||readlink($path)!=='"'"'/boot/config/plugins/'"'"'.basename($path))throw new RuntimeException('"'"'Unsafe plugin link: '"'"'.$path);
}elseif(file_exists($path)&&!is_file($path)&&!is_dir($path))throw new RuntimeException('"'"'Unsafe installer object: '"'"'.$path);
}
function zfsdLegacyPlan(string $root,string $keep): array {
if(!in_array($keep,['"'"'zfs-director.plg'"'"','"'"'zfs.master.ui.plg'"'"','"'"'zfs.master.ui.local.plg'"'"'],true))throw new RuntimeException('"'"'Unknown installer registration'"'"');
$root=rtrim($root,'"'"'/'"'"');$moves=[];$cron=[];
zfsdLegacyPath($root.'"'"'/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director'"'"');
$mounts=@file_get_contents($root.'"'"'/proc/self/mountinfo'"'"');
if($mounts===false&&$root==='"'"''"'"')throw new RuntimeException('"'"'Cannot verify legacy directory mount boundaries'"'"');
foreach(explode("\n",(string)$mounts)as$line){
$fields=explode('"'"' '"'"',$line);if(count($fields)<6)continue;
$mount=preg_replace_callback('"'"'/\\\\([0-7]{3})/'"'"',static fn($m)=>chr(octdec($m[1])),$fields[4]);
foreach(['"'"'/usr/local/emhttp/plugins/zfs.master'"'"','"'"'/usr/local/emhttp/plugins/zfs.master.ui'"'"','"'"'/boot/config/plugins/zfs.master'"'"']as$tree){
$tree=$root.$tree;if($mount===$tree||str_starts_with($mount,$tree.'"'"'/'"'"'))throw new RuntimeException('"'"'Mounted data inside a plugin directory requires inspection: '"'"'.$mount);
}
}
foreach(['"'"'zfs.master.plg'"'"','"'"'zfs-director.plg'"'"','"'"'zfs.master.ui.plg'"'"','"'"'zfs.master.ui.local.plg'"'"']as$name){
if($name===$keep)continue;
foreach(['"'"'/boot/config/plugins/'"'"','"'"'/var/log/plugins/'"'"','"'"'/boot/config/plugins/zfs.master.ui/'"'"']as$folder){
$p=$root.$folder.$name;zfsdLegacyPath($p,$folder==='"'"'/var/log/plugins/'"'"');
if(is_dir($p)&&!is_link($p))throw new RuntimeException('"'"'Plugin registration is a directory: '"'"'.$p);
if(file_exists($p)||is_link($p))$moves[]=$p;
}
}
foreach(['"'"'/usr/local/emhttp/plugins/zfs.master'"'"','"'"'/boot/config/plugins/zfs.master'"'"']as$folder){
$p=$root.$folder;zfsdLegacyPath($p);
if(is_link($p)||(file_exists($p)&&!is_dir($p)))throw new RuntimeException('"'"'Unsafe legacy plugin directory: '"'"'.$p);
if(is_dir($p)){
if(glob($p.'"'"'/*.cron'"'"'))throw new RuntimeException('"'"'Legacy ZFS Master has custom schedules; inspect them before replacing its configuration'"'"');
$moves[]=$p;
}
}
// Obsolete packages are code, not user state. Retain them in the same
// rollback backup instead of leaving old installers in the state folder.
foreach(glob($root.'"'"'/boot/config/plugins/zfs.master.ui/zfs.master.ui-*.tgz'"'"')?:[]as$p){
if(!preg_match('"'"'/^zfs\.master\.ui-[0-9.]+\.tgz$/D'"'"',basename($p)))continue;
zfsdLegacyPath($p);if(!is_file($p)||is_link($p))throw new RuntimeException('"'"'Unsafe legacy package: '"'"'.$p);$moves[]=$p;
}
foreach(['"'"'zfs-director'"'"','"'"'zfs.master.ui'"'"','"'"'zfs.master.ui.local'"'"']as$namespace){
$p=$root.'"'"'/boot/config/plugins/'"'"'.$namespace.'"'"'/protection.cron'"'"';zfsdLegacyPath($p);
foreach(glob(dirname($p).'"'"'/*.cron'"'"')?:[]as$other)if($other!==$p)throw new RuntimeException('"'"'Custom schedules in an old Director namespace require inspection: '"'"'.$other);
if(is_link($p)||(file_exists($p)&&(!is_file($p)||filesize($p)>1048576)))throw new RuntimeException('"'"'Unsafe Director cron source: '"'"'.$p);
if(is_file($p))$cron[$p]=file_get_contents($p);
}
$destination=$root.'"'"'/boot/config/plugins/'"'"'.basename($keep,'"'"'.plg'"'"').'"'"'/protection.cron'"'"';
$selected=null;$active=[];
foreach($cron as$p=>$text){
$lines=array_values(array_filter(explode("\n",$text),static fn($line)=>trim($line)!=='"'"''"'"'&&!str_starts_with(ltrim($line),'"'"'#'"'"')));
if($lines)$active[$p]=$text;
}
if(count(array_unique($active))>1)throw new RuntimeException('"'"'Conflicting Director schedules; no registration or schedule was removed'"'"');
if($active)$selected=array_key_first($active);
elseif(isset($cron[$destination]))$selected=$destination;
elseif($cron)$selected=array_key_first($cron);
foreach($cron as$p=>$text)if($p!==$destination)$moves[]=$p;
// A different empty destination may be replaced by the existing active
// source, but is backed up just like every displaced registration.
if($selected!==null&&$selected!==$destination&&isset($cron[$destination]))$moves[]=$destination;
return ['"'"'moves'"'"'=>array_values(array_unique($moves)),'"'"'cron_target'"'"'=>$destination,'"'"'cron_text'"'"'=>$selected!==null?$cron[$selected]:null,
'"'"'write_cron'"'"'=>$selected!==null&&($cron[$destination]??null)!==$cron[$selected]];
}
function zfsdLegacyProcesses(string $root): array {
$nchan=[];$legacy=rtrim($root,'"'"'/'"'"').'"'"'/usr/local/emhttp/plugins/zfs.master/'"'"';
foreach(glob(rtrim($root,'"'"'/'"'"').'"'"'/proc/[0-9]*/cmdline'"'"')?:[]as$file){
$args=explode("\0",(string)@file_get_contents($file));$matched=false;
foreach($args as$arg)if(str_starts_with($arg,$legacy))$matched=true;
if(!$matched)continue;
$script=$args[1]??'"'"''"'"';if($script==='"'"'-q'"'"')$script=$args[2]??'"'"''"'"';
if(basename($args[0])!=='"'"'php'"'"'||$script!==$legacy.'"'"'nchan/zfs_master'"'"')throw new RuntimeException('"'"'An operation from ZFS Master is still running; finish it before installation'"'"');
$nchan[(int)basename(dirname($file))]=(string)file_get_contents($file);
}
return $nchan;
}
function zfsdLegacyInstall(string $mode,string $root,string $keep,string $backup): void {
$root=rtrim($root,'"'"'/'"'"');
if($mode==='"'"'check'"'"'){zfsdLegacyPlan($root,$keep);zfsdLegacyProcesses($root);return;}
zfsdLegacyPath($backup);
if($mode==='"'"'rollback'"'"'){
$journal=$backup.'"'"'/migration.json'"'"';if(!is_file($journal))return;
$state=json_decode(file_get_contents($journal),true,64,JSON_THROW_ON_ERROR);
if(!empty($state['"'"'rolled_back'"'"']))return;
if(isset($state['"'"'created_cron'"'"'])){
[$target,$sha]=$state['"'"'created_cron'"'"'];zfsdLegacyPath($target);
if(file_exists($target)){
$restored=false;$current=lstat($target);
foreach($state['"'"'done'"'"']as$item)if($item[0]===$target&&[$current['"'"'dev'"'"'],$current['"'"'ino'"'"']]===$item[3]&&!file_exists($item[1]))$restored=true;
if(!$restored){
if(is_link($target)||!is_file($target)||hash_file('"'"'sha256'"'"',$target)!==$sha)throw new RuntimeException('"'"'Cron changed after installation; retained backup: '"'"'.$backup);
if(!unlink($target))throw new RuntimeException('"'"'Cannot undo Director cron relocation'"'"');
}
}
}
foreach(array_reverse($state['"'"'done'"'"'])as$item){
[$from,$to,$link,$identity]=$item;clearstatcache();
if(file_exists($from)||is_link($from)){
$stat=lstat($from);
if(($link!==null&&is_link($from)&&readlink($from)===$link)||($link===null&&[$stat['"'"'dev'"'"'],$stat['"'"'ino'"'"']]===$identity&&!file_exists($to)))continue;
throw new RuntimeException('"'"'Rollback target changed; retained backup: '"'"'.$backup);
}
if($link!==null){if(!symlink($link,$from))throw new RuntimeException('"'"'Cannot restore plugin registration'"'"');}
elseif(!rename($to,$from))throw new RuntimeException('"'"'Cannot restore displaced plugin file'"'"');
}
$state['"'"'rolled_back'"'"']=true;
if(file_put_contents($journal.'"'"'.tmp'"'"',json_encode($state,JSON_THROW_ON_ERROR),LOCK_EX)===false||!rename($journal.'"'"'.tmp'"'"',$journal))throw new RuntimeException('"'"'Cannot record completed rollback'"'"');
return;
}
if($mode!=='"'"'apply'"'"')throw new RuntimeException('"'"'Unknown legacy cleanup action'"'"');
$plan=zfsdLegacyPlan($root,$keep);$processes=zfsdLegacyProcesses($root);
if($processes&&$root!=='"'"''"'"')throw new RuntimeException('"'"'Process changes require the live system root'"'"');
foreach($processes as$pid=>$cmd){
if((string)@file_get_contents('"'"'/proc/'"'"'.$pid.'"'"'/cmdline'"'"')!==$cmd)throw new RuntimeException('"'"'Legacy monitor identity changed'"'"');
if(!posix_kill($pid,15))throw new RuntimeException('"'"'Unable to stop the legacy ZFS Master monitor'"'"');
}
$deadline=microtime(true)+5;
do{$running=zfsdLegacyProcesses($root);if(!$running)break;usleep(100000);}while(microtime(true)<$deadline);
if($running)throw new RuntimeException('"'"'Legacy ZFS Master monitor has not stopped'"'"');
if(!$plan['"'"'moves'"'"']&&!$plan['"'"'write_cron'"'"'])return;
if(file_exists($backup)||!mkdir($backup,0700,true))throw new RuntimeException('"'"'Cannot create legacy plugin backup'"'"');
$state=['"'"'done'"'"'=>[]];$journal=$backup.'"'"'/migration.json'"'"';
$save=static function()use(&$state,$journal){$json=json_encode($state,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);if(file_put_contents($journal.'"'"'.tmp'"'"',$json,LOCK_EX)!==strlen($json)||!rename($journal.'"'"'.tmp'"'"',$journal))throw new RuntimeException('"'"'Cannot save installer rollback journal'"'"');};
$save();
try{
foreach($plan['"'"'moves'"'"']as$i=>$from){
$link=is_link($from)?readlink($from):null;
$to=is_dir($from)&&str_starts_with($from,$root.'"'"'/usr/local/emhttp/plugins/'"'"')?dirname($from).'"'"'/.zfs-director-legacy-'"'"'.basename($backup).'"'"'-'"'"'.$i:$backup.'"'"'/item-'"'"'.$i;
if(file_exists($to)||is_link($to))throw new RuntimeException('"'"'Legacy backup destination exists'"'"');
$stat=lstat($from);$state['"'"'done'"'"'][]=[$from,$to,$link,[$stat['"'"'dev'"'"'],$stat['"'"'ino'"'"']]];$save();
if($link!==null){if(!unlink($from))throw new RuntimeException('"'"'Cannot remove old registration'"'"');}
elseif(!rename($from,$to))throw new RuntimeException('"'"'Cannot retain old plugin file'"'"');
}
if($plan['"'"'write_cron'"'"']){
$target=$plan['"'"'cron_target'"'"'];if(!is_dir(dirname($target))&&!mkdir(dirname($target),0700,true))throw new RuntimeException('"'"'Cannot preserve Director cron namespace'"'"');
if(file_exists($target)||is_link($target))throw new RuntimeException('"'"'Director cron changed during installation'"'"');
$text=$plan['"'"'cron_text'"'"'];$state['"'"'created_cron'"'"']=[$target,hash('"'"'sha256'"'"',$text)];$save();
$temporary=tempnam(dirname($target),'"'"'.director-cron-'"'"');
if($temporary===false)throw new RuntimeException('"'"'Cannot stage Director cron'"'"');
try{if(file_put_contents($temporary,$text,LOCK_EX)!==strlen($text)||!rename($temporary,$target)||file_get_contents($target)!==$text)throw new RuntimeException('"'"'Director cron readback failed'"'"');}
finally{if(is_file($temporary))unlink($temporary);}
}
echo '"'"'ZFS Director: old ZFS Master code and duplicate registrations removed; rollback backup: '"'"'.$backup."\n";
}catch(Throwable$error){
zfsdLegacyInstall('"'"'rollback'"'"',$root,$keep,$backup);throw $error;
}
}
try { zfsdLegacyInstall($argv[1], $argv[2], $argv[3], $argv[4]); } catch (Throwable $error) { echo "ZFS Director: ".$error->getMessage()."\n"; exit(1); }' -- "$1" / "$registration" "$legacy_backup"
}
legacy_cleanup check
for directory in "$live" /boot/config/plugins/zfs.master.ui/protection; do
if [[ -L "$directory" || ( -e "$directory" && ! -d "$directory" ) ]]; then echo "ZFS Director: unsafe installer directory: $directory"; exit 1; fi
done
for object in /var/lock/zfs.master.ui.lock /boot/config/plugins/zfs.master.ui/protection/config.lock /boot/config/plugins/zfs.master.ui/protection/execution.lock; do
if [[ -L "$object" || ( -e "$object" && ! -f "$object" ) ]]; then echo "ZFS Director: unsafe installer lock: $object"; exit 1; fi
done
hook=/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director
if [[ -L "$hook" || ( -e "$hook" && ! -f "$hook" ) ]]; then echo 'ZFS Director: unsafe Plugin Manager hook'; exit 1; fi
if [[ -f "$hook" && "$(sha256sum "$hook" | cut -d ' ' -f 1)" != 'f16a1c7f2e5fd19ec09bc9eb6e7c2b396fa28253e3269f9e31d08d10237cb3c3' ]]; then echo 'ZFS Director: existing post-hook has unknown ownership; retained unchanged'; exit 1; fi
# End legacy migration guard.
# Runtime stop marker guard.
stop_runtime() {
/usr/bin/perl -e 'use strict;
use warnings;
use Fcntl qw(:DEFAULT :mode);
my ($base, $owner, $message) = @ARGV;
die "Invalid runtime guard arguments\n" unless defined($message) && $owner =~ /\A[0-9]+\z/ && $base =~ m{\A/} && $base !~ m{(?:\A|/)\.\.?(/|\z)};
sub enter_directory {
my ($name, $uid, $shared) = @_;
sysopen(my $directory, $name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) or die "Runtime directory requires inspection\n";
my @stat = stat($directory);
die "Untrusted runtime directory\n" unless @stat && S_ISDIR($stat[2]) && $stat[4] == $uid
&& (!(($stat[2]) & 0022) || ($shared && ($stat[2] & 01000)));
chdir($directory) or die "Unable to enter trusted runtime directory\n";
}
enter_directory('"'"'/'"'"', 0, 0);
my @parents = grep { length } split m{/}, $base;
for my $index (0 .. $#parents) {
enter_directory($parents[$index], $index == $#parents ? $owner : 0, 1);
}
for my $name ('"'"'zfs.master.ui'"'"', '"'"'jobs'"'"') {
if (!lstat($name)) { mkdir($name, 0700) or die "Unable to create runtime directory\n"; }
enter_directory($name, $owner, 0);
}
my @stop = lstat('"'"'stop'"'"');
die "Stop marker requires inspection\n" if @stop && (!S_ISREG($stop[2]) || $stop[4] != $owner || ($stop[2] & 0022) || $stop[3] != 1);
my $temporary = '"'"'.stop-'"'"' . $$ . '"'"'-'"'"' . int(rand(1000000000));
sysopen(my $marker, $temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600) or die "Unable to create stop marker\n";
eval {
print {$marker} $message, "\n" or die "Unable to write stop marker\n";
close($marker) or die "Unable to close stop marker\n";
rename($temporary, '"'"'stop'"'"') or die "Unable to publish stop marker\n";
1;
} or do {
my $error = $@;
close($marker) if defined(fileno($marker));
unlink($temporary);
die $error;
};
' -- /var/tmp 0 "$1"
}
# End runtime stop marker guard.
# Boot reconstruction restores the UI even when recovery must stay stopped.
installed=/boot/config/plugins/zfs.master.ui/installed.sha256
[[ ! -L "$installed" && ( ! -e "$installed" || -f "$installed" ) ]]
restore_accepted=0
hold_recovery=0
old_receipt=
if [[ -f "$installed" ]]; then old_receipt=$(cat "$installed"); fi
if [[ ! -e "$live" && -f "$installed" && "$(cat "$installed")" == 'ab5339f5398e20edf41cfb3769e98db0db25f56a2e28589a270e5ee140ad7c2d' ]]; then restore_accepted=1; fi
preflight() {
local status=0 mode=install
if [[ "$restore_accepted" == 1 ]]; then mode=accepted-boot; fi
/usr/bin/php -r '
// Read-only installer prerequisite. No plugin includes, worker or scheduler calls.
if(PHP_SAPI!=='"'"'cli'"'"')exit(2);
$root=$argv[1]??'"'"'/boot/config/plugins/zfs.master.ui'"'"';
$acceptedBoot=($argv[2]??'"'"''"'"')==='"'"'accepted-boot'"'"';
$managedInstall=($argv[2]??'"'"''"'"')==='"'"'install'"'"';
$requireRecovery=static function($message)use($acceptedBoot){fwrite(STDERR,'"'"'ZFS Director preflight: '"'"'.$message."\n");exit($acceptedBoot?10:1);};
try{
if(!is_string($root)||!str_starts_with($root,'"'"'/'"'"')||str_contains($root,"\0")||str_contains($root,'"'"'..'"'"')||is_link($root))throw new RuntimeException('"'"'Unsafe persistent state path'"'"');
$config=$root.'"'"'/zfs.master.ui.cfg'"'"';
if(file_exists($config)||is_link($config)){
if(is_link($config)||!is_file($config)||filesize($config)>1048576)throw new RuntimeException('"'"'Settings require inspection'"'"');
$parsed=@parse_ini_file($config,true,INI_SCANNER_RAW);if(!is_array($parsed))$requireRecovery('"'"'Settings cannot be parsed; restore stopped for inspection'"'"');
$flag=$parsed['"'"'general'"'"']['"'"'sanoid_integration'"'"']??'"'"'0'"'"';if((string)$flag!=='"'"'0'"'"'&&!(($acceptedBoot||$managedInstall)&&(string)$flag==='"'"'1'"'"'))$requireRecovery('"'"'Integration settings require inspection before upgrade'"'"');
}
$jobs=$root.'"'"'/jobs'"'"';if(is_link($jobs))throw new RuntimeException('"'"'Jobs path requires inspection'"'"');
$pendingJobs=[];
$scanJobs=function($directory,$skipIfDurable=null)use(&$pendingJobs,$requireRecovery){
if(!is_dir($directory))return;
$scan=opendir($directory);$count=0;
// Only job journals count toward the bound: every journal also keeps a .lock file and may
// sit next to other files, so counting raw directory entries refused healthy installs.
try{while(($name=readdir($scan))!==false){if(!preg_match('"'"'/^[0-9]{8}-[0-9]{6}-[a-f0-9]{12}\.json$/D'"'"',$name))continue;if(++$count>50000)$requireRecovery('"'"'Jobs scan incomplete: more than 50000 job journals; restore stopped for inspection'"'"');
// A promotion can crash between writing the durable copy and removing this leftover
// (REL-002); once a durable record exists for this id, it alone is authoritative.
if($skipIfDurable!==null&&is_file($skipIfDurable.'"'"'/'"'"'.$name))continue;
$file=$directory.'"'"'/'"'"'.$name;
if(is_link($file)||!is_file($file)||filesize($file)>131072)throw new RuntimeException('"'"'Job requires inspection before upgrade'"'"');$j=json_decode(file_get_contents($file),true,32);if(!is_array($j))$requireRecovery('"'"'Malformed job requires inspection'"'"');
if(!is_array($j)||!is_string($j['"'"'state'"'"']??null))$requireRecovery('"'"'A job with an unreadable state requires inspection; background services stay paused.'"'"');
if(in_array($j['"'"'state'"'"'],['"'"'queued'"'"','"'"'running'"'"'],true)){$label=trim(preg_replace('"'"'/[^\x20-\x7e]+/'"'"','"'"''"'"',(string)($j['"'"'type'"'"']??'"'"'job'"'"').'"'"' '"'"'.(string)($j['"'"'source'"'"']??$j['"'"'label'"'"']??'"'"''"'"')));$pendingJobs[]=substr($label==='"'"''"'"'?'"'"'job'"'"':$label,0,90).'"'"' ('"'"'.$j['"'"'state'"'"'].'"'"')'"'"';continue;}
// Terminal file jobs can still release holds or delete ZIP staging.
// Future expiry/retry times only defer that work after activation.
if(in_array($j['"'"'type'"'"']??null,['"'"'restore'"'"','"'"'archive'"'"'],true)&&($j['"'"'hold_owned'"'"']??null)!==false)$requireRecovery('"'"'File job protection requires inspection; worker stays stopped'"'"');
if(($j['"'"'type'"'"']??null)==='"'"'archive'"'"'&&($j['"'"'archive_removed'"'"']??null)!==true)$requireRecovery('"'"'Retained ZIP cleanup requires inspection; worker stays stopped'"'"');
}}finally{closedir($scan);}
};
$scanJobs($jobs);
// A scheduled policy check (REL-002) is queued only in the tmpfs runtime directory while
// idle-capable; an update must refuse to proceed while one is in flight there too.
$runtimeJobs='"'"'/var/tmp/'"'"'.basename($root).'"'"'/jobs/protection-queue'"'"';if(is_link($runtimeJobs))throw new RuntimeException('"'"'Jobs path requires inspection'"'"');
$scanJobs($runtimeJobs,$jobs);
if($pendingJobs){
$shown=implode('"'"'; '"'"',array_slice($pendingJobs,0,3)).(count($pendingJobs)>3?'"'"'; and '"'"'.(count($pendingJobs)-3).'"'"' more'"'"':'"'"''"'"');
$requireRecovery('"'"'Update paused: '"'"'.count($pendingJobs).'"'"' job(s) still queued or running: '"'"'.$shown.'"'"'. Nothing was changed and the installed version keeps running. Next: open Main > ZFS Director > Jobs, wait for these jobs to finish or cancel them, then run Plugins > Check for Updates again.'"'"');
}
echo "ZFS Director preflight: no pending execution or file cleanup. Settings and schedules retained.\n";
}catch(Throwable$e){fwrite(STDERR,'"'"'ZFS Director upgrade blocked: '"'"'.$e->getMessage()."\n");exit(1);}
' -- /boot/config/plugins/zfs.master.ui "$mode" || status=$?
if [[ "$status" == 10 && "$restore_accepted" == 1 ]]; then hold_recovery=1; elif [[ "$status" != 0 ]]; then return "$status"; fi
}
preflight
# Read-only prerequisite gate, before stopping the installed implementation.
# Execute the exact packaged helper; the generic probe creates no files.
if ! /usr/bin/perl -e '#!/usr/bin/perl
# Linux directory mutations. Every destructive name is first captured into a
# root-owned private directory and checked by dev/ino. A complete filesystem
# root confines renames; FAN_CREATE handles authenticate the private parent.
use strict;
use warnings;
use JSON::PP;
use Fcntl qw(:DEFAULT :mode);
use POSIX qw(uname);
use Encode qw(encode decode FB_CROAK LEAVE_SRC);
use IO::Handle;
use Errno qw(EBADF);
use Digest::SHA ();
use MIME::Base64 ();
use Time::HiRes ();
my $json=JSON::PP->new->utf8->canonical;
my %nr;
my ($root,$container,$container_name,$root_path,$journal,$source_location,$publication,$boundary);
my $serial=0;
my ($delete_total,$delete_removed,$delete_tick)=(0,0,0);
# Set while emptying a tree already captured in the private container: names
# are still claimed one by one, but durability is flushed once at the end
# instead of fsyncing the journal and directories for every file.
my $bulk=0;
sub deletion_progress {
$delete_removed++;
if (Time::HiRes::time()-$delete_tick>=0.5||$delete_removed==$delete_total) {
emit({phase=>'"'"'deleting'"'"',removed=>$delete_removed,total=>$delete_total});$delete_tick=Time::HiRes::time();
}
}
use constant O_PATH_LINUX => 0x200000;
use constant O_CLOEXEC_LINUX => 0x80000;
sub fail { die $_[0]."\n" }
sub raw { encode('"'"'UTF-8'"'"',$_[0],FB_CROAK|LEAVE_SRC) }
sub emit { print $json->encode($_[0])."\n" or fail('"'"'Progress channel closed'"'"'); STDOUT->flush; }
sub identity { my @s=stat($_[0]); fail('"'"'Pinned object unavailable'"'"') unless @s; return {dev=>"$s[0]",ino=>"$s[1]"}; }
sub same { my ($a,$b)=@_; return ref($b) eq '"'"'HASH'"'"' && defined($b->{dev}) && defined($b->{ino}) && $a->{dev} eq "$b->{dev}" && $a->{ino} eq "$b->{ino}"; }
sub path {
my ($value)=@_; fail('"'"'Invalid directory path'"'"') if !defined($value)||ref($value)||length($value)>4096||$value=~m{^/|\0};
my @parts=split m{/},$value,-1;
fail('"'"'Invalid directory path'"'"') if !@parts||@parts>128||grep { $_ eq '"'"''"'"'||$_ eq '"'"'.'"'"'||$_ eq '"'"'..'"'"'||$_ eq '"'"'.zfs'"'"'||/^\.zfsm-/ } @parts;
return $value;
}
sub openat {
my ($parent,$name,$flags,$allow_mount)=@_;
my $how=pack('"'"'Q<Q<Q<'"'"',$flags,0,8|4|($allow_mount?0:1)); my $p=raw($name);
my $fd=syscall($nr{openat2},fileno($parent),$p,$how,length($how));
fail('"'"'Path is unavailable, contains a symbolic link, or crosses a mount: '"'"'.$name) if $fd<0;
open(my $fh,'"'"'<&='"'"',$fd) or fail('"'"'Unable to retain directory handle'"'"'); return $fh;
}
sub anchor { '"'"'/proc/self/fd/'"'"'.fileno($_[0]) }
sub entries {
my ($dir)=@_; opendir(my $dh,anchor($dir)) or fail('"'"'Unable to inspect pinned directory'"'"'); my @out;
while(defined(my $p=readdir($dh))) { next if $p eq '"'"'.'"'"'||$p eq '"'"'..'"'"'; push @out,decode('"'"'UTF-8'"'"',$p,FB_CROAK); fail('"'"'Directory exceeds 100000 entries'"'"') if @out>100000; }
closedir($dh); return sort @out;
}
sub renamed {
my ($from,$old,$to,$new)=@_; my $a=raw($old); my $b=raw($new);
return syscall($nr{renameat2},fileno($from),$a,fileno($to),$b,1)==0;
}
sub parent {
my ($name)=@_; my @parts=split m{/},$name; my $leaf=pop @parts;
return (openat($root,@parts?join('"'"'/'"'"',@parts):'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0),$leaf);
}
sub canonical_parent {
my ($path,$pinned)=@_;my ($current)=parent($path);
return same(identity($current),identity($pinned));
}
sub recovery {
return undef unless $container;
my $where=readlink(anchor($container));
my $details={container=>$container_name,location=>defined($where)?$where:'"'"'unavailable'"'"',identity=>identity($container),journal=>'"'"'journal.jsonl'"'"',retained=>JSON::PP::true};
$details->{source}=$source_location if $source_location;
if ($publication) {
my $actual=readlink(anchor($publication->{handle}));
$details->{publication}={requested=>$publication->{requested},location=>defined($actual)?$actual:'"'"'unavailable'"'"',identity=>identity($publication->{handle})};
}
return $details;
}
sub record {
my ($row)=@_; print $journal $json->encode($row)."\n" or fail('"'"'Unable to write recovery journal'"'"');
$journal->flush && ($bulk||$journal->sync) or fail('"'"'Unable to flush recovery journal'"'"');
}
sub mount_id {
my ($dir)=@_; open(my $info,'"'"'<'"'"','"'"'/proc/self/fdinfo/'"'"'.fileno($dir)) or fail('"'"'Unable to inspect pinned mount identity'"'"');
local $/; my $value=<$info>; close($info);
fail('"'"'Pinned mount identity unavailable'"'"') unless $value=~/^mnt_id:\s*(\d+)\s*$/m; return "$1";
}
sub full_root {
my ($dir)=@_; my $id=mount_id($dir); my $found;
open(my $info,'"'"'<'"'"','"'"'/proc/self/mountinfo'"'"') or fail('"'"'Unable to inspect filesystem mount roots'"'"');
while(my $line=<$info>) {
my @fields=split / /,$line; next unless $fields[0] eq $id;
fail('"'"'Dataset mutation root must be a complete filesystem root'"'"') unless $fields[3] eq '"'"'/'"'"';
my $point=$fields[4]; $point=~s/\\([0-7]{3})/chr(oct($1))/ge; $point=decode('"'"'UTF-8'"'"',$point,FB_CROAK);
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to inspect filesystem mount root'"'"');
my $mounted=$point eq '"'"'/'"'"'?$slash:openat($slash,substr($point,1),O_RDONLY|O_DIRECTORY,1);
fail('"'"'Dataset mutation root must be a complete filesystem root'"'"') unless same(identity($mounted),identity($dir)) && mount_id($mounted) eq $id;
$found=1; last;
}
close($info); fail('"'"'Pinned filesystem mount root unavailable'"'"') unless $found; return $id;
}
sub file_handle {
my ($dir)=@_; my $handle=pack('"'"'L<l<'"'"',128,0).("\0"x128); my $mount=pack('"'"'l<'"'"',0); my $empty='"'"''"'"';
fail('"'"'Filesystem object handles are required'"'"') if syscall($nr{name_to_handle_at},fileno($dir),$empty,$handle,$mount,0x1000)!=0;
my ($length,$type)=unpack('"'"'L<l<'"'"',$handle); fail('"'"'Invalid filesystem object handle'"'"') if !$length||$length>128;
return pack('"'"'l<'"'"',$type).substr($handle,8,$length);
}
sub filesystem_id {
my ($dir)=@_; my $value="\0"x128;
fail('"'"'Unable to inspect owning filesystem identity'"'"') if syscall($nr{fstatfs},fileno($dir),$value)!=0;
return substr($value,56,8);
}
sub fanotify {
# Directory entry notifications must include both the parent and target FID.
my $fd=syscall($nr{fanotify_init},0x1e03,O_RDONLY|O_CLOEXEC_LINUX);
fail('"'"'Safe directory mutations require fanotify target object handles'"'"') if $fd<0;
open(my $fan,'"'"'<&='"'"',$fd) or fail('"'"'Unable to retain creation notification handle'"'"'); return $fan;
}
sub prepare_boundary {
my ($dir)=@_; my $id=full_root($dir); my $handle=file_handle($dir); my $fsid=filesystem_id($dir); my $fan=fanotify(); my $dot='"'"'.'"'"';
fail('"'"'Filesystem creation notifications are required'"'"') if syscall($nr{fanotify_mark},fileno($fan),1|8,0x48000100,fileno($dir),$dot)!=0;
return {mount_id=>$id,handle=>$handle,fsid=>$fsid,fan=>$fan};
}
sub creation_handle {
my ($policy,$name)=@_; my $ready='"'"''"'"'; vec($ready,fileno($policy->{fan}),1)=1;
fail('"'"'Private directory creation notification unavailable'"'"') unless select($ready,undef,undef,2)>0;
my $bytes='"'"''"'"'; my $length=sysread($policy->{fan},$bytes,65536);
fail('"'"'Unable to read private directory creation notification'"'"') unless defined($length)&&$length>0;
my ($offset,$target)=(0,undef);
while($offset<$length) {
fail('"'"'Malformed private directory creation notification'"'"') if $length-$offset<24;
my ($event_length,$version,$reserved,$metadata_length,$mask,$fd,$pid)=unpack('"'"'L<CCS<Q<l<l<'"'"',substr($bytes,$offset,24));
fail('"'"'Invalid private directory creation notification'"'"') if $version!=3||$reserved||$metadata_length!=24||$event_length<$metadata_length||$offset+$event_length>$length||($mask&0x4000)||$fd!=-1;
my (%records,$position); $position=$offset+$metadata_length;
while($position<$offset+$event_length) {
fail('"'"'Malformed creation identity record'"'"') if $offset+$event_length-$position<20;
my ($kind,$pad,$size)=unpack('"'"'CCS<'"'"',substr($bytes,$position,4));
fail('"'"'Unexpected creation identity record'"'"') if $pad||($kind!=1&&$kind!=2)||$size<20||$position+$size>$offset+$event_length||exists($records{$kind});
my $fsid=substr($bytes,$position+4,8); my ($handle_length,$type)=unpack('"'"'L<l<'"'"',substr($bytes,$position+12,8));
fail('"'"'Invalid creation filesystem identity'"'"') if $fsid ne $policy->{fsid}||!$handle_length||$handle_length>128||20+$handle_length>$size;
my $tail=substr($bytes,$position+20+$handle_length,$size-20-$handle_length);
fail('"'"'Malformed target creation identity'"'"') if $kind==1&&$tail=~/[^\0]/;
$records{$kind}={handle=>pack('"'"'l<'"'"',$type).substr($bytes,$position+20,$handle_length),tail=>$tail};
$position+=$size;
}
fail('"'"'Missing creation identity records'"'"') unless $position==$offset+$event_length&&keys(%records)==2&&$records{1}&&$records{2};
my $tail=$records{2}{tail}; fail('"'"'Malformed created directory name'"'"') unless $tail=~/\A([^\0]*)\0\0*\z/s;
my $event_name=$1;
if ($pid==$$ && $mask==0x40000100 && $event_name eq raw($name)) {
fail('"'"'Private directory creation parent changed'"'"') unless $records{2}{handle} eq $policy->{handle};
fail('"'"'Ambiguous private directory creation identity'"'"') if defined($target); $target=$records{1}{handle};
}
$offset+=$event_length;
}
fail('"'"'Private directory creation identity unavailable'"'"') unless defined($target); return $target;
}
sub probe {
my ($args)=@_;
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to pin filesystem root'"'"');
my $pinned=openat($slash,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0);
my $a='"'"'zfsm-readonly-probe'"'"';my $b='"'"'zfsm-readonly-probe'"'"';
my $result=syscall($nr{renameat2},-1,$a,-1,$b,1);
fail('"'"'Safe directory mutations require renameat2 RENAME_NOREPLACE'"'"') unless $result<0 && $!==EBADF;
my $fan=fanotify(); my $handle=pack('"'"'L<l<'"'"',128,0).("\0"x128);my $mount=pack('"'"'l<'"'"',0);my $empty='"'"''"'"';
my $result_handle=syscall($nr{name_to_handle_at},-1,$empty,$handle,$mount,0x1000);
fail('"'"'Safe directory mutations require name_to_handle_at'"'"') unless $result_handle<0&&$!==EBADF;
my $dot='"'"'.'"'"'; my $result_mark=syscall($nr{fanotify_mark},-1,1|8,0x48000100,-1,$dot);
fail('"'"'Safe directory mutations require fanotify_mark'"'"') unless $result_mark<0&&$!==EBADF;
my $row={ok=>JSON::PP::true,op=>'"'"'probe'"'"',fanotify_target_fid=>JSON::PP::true};
if (exists($args->{root})) { my $dir=request_root($args); my $policy=prepare_boundary($dir); $row->{root_mount_id}=$policy->{mount_id}; }
emit($row);
}
sub container {
sysopen(my $random,'"'"'/dev/urandom'"'"',O_RDONLY) or fail('"'"'Unable to obtain private claim name'"'"');
read($random,my $bytes,24)==24 or fail('"'"'Unable to obtain private claim name'"'"'); close($random);
$container_name='"'"'.zfsm-mutation-'"'"'.unpack('"'"'H*'"'"',$bytes); my $name=raw($container_name);
fail('"'"'Unable to create private recovery container'"'"') if syscall($nr{mkdirat},fileno($root),$name,0700)!=0;
my $created=creation_handle($boundary,$container_name);
my $candidate=openat($root,$container_name,O_RDONLY|O_DIRECTORY,0); my @s=stat($candidate);
fail('"'"'Private recovery container creation identity changed'"'"') unless mount_id($candidate) eq $boundary->{mount_id} && file_handle($candidate) eq $created;
fail('"'"'Private recovery container ownership changed'"'"') unless $s[4]==0 && ($s[2]&0777)==0700 && !($s[2]&01000);
$container=$candidate;
# A setgid dataset parent can contribute its group bit; clear it only after
# authenticating the created inode, never on an unrelated substituted dir.
chmod(0700,$container)==1 or fail('"'"'Unable to secure proven private recovery container'"'"');
fail('"'"'Private recovery container is not empty; no metadata written'"'"') if entries($container);
# The public parent may be0777. Never unlink the public container name:
# Linux lacks inode-conditional unlinkat. Retain the journal on success too.
my $file=anchor($container).'"'"'/journal.jsonl'"'"';
sysopen($journal,$file,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or fail('"'"'Unable to create recovery journal'"'"');
record({event=>'"'"'created'"'"',container_identity=>identity($container)});
$container->sync && $root->sync or fail('"'"'Unable to flush private recovery container'"'"');
emit({phase=>'"'"'prepared'"'"',recovery=>recovery()});
}
sub capture {
my ($from,$name,$expected,$original)=@_; my $slot='"'"'object-'"'"'.(++$serial);
record({event=>'"'"'intent'"'"',slot=>$slot,path=>$original,identity=>$expected});
fail('"'"'Directory changed or cannot be claimed; no destination was overwritten'"'"') unless renamed($from,$name,$container,$slot);
($bulk||($from->sync && $container->sync)) or fail('"'"'Unable to flush claim; inspect recovery journal'"'"');
my $captured;
eval { $captured=openat($container,$slot,O_PATH_LINUX|O_NOFOLLOW,0); 1 } or do {
my $error=$@; my $restored=renamed($container,$slot,$from,$name);
$from->sync && $container->sync or fail('"'"'Unable to flush rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$original});
fail($restored?'"'"'Captured object is unavailable; original name restored'"'"':$error.'"'"'; object retained; original name occupied or moved'"'"');
};
if (!same(identity($captured),$expected)) {
my $restored=renamed($container,$slot,$from,$name);
$from->sync && $container->sync or fail('"'"'Unable to flush rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$original,identity=>identity($captured)});
fail($restored?'"'"'Directory identity changed; unexpected object restored'"'"':'"'"'Directory identity changed; unexpected object retained; original name occupied or moved'"'"');
}
my @captured_stat=stat($captured);
$captured=openat($captured,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0) if S_ISDIR($captured_stat[2]);
my $where=readlink(anchor($from));
my $observed=defined($where)?$where.'"'"'/'"'"'.$name:'"'"'unavailable'"'"';
$source_location={requested=>$original,location=>$observed} if $serial==1;
record({event=>'"'"'captured'"'"',slot=>$slot,path=>$original,source_location=>$observed,identity=>$expected});
return ($captured,$slot);
}
sub inspect_tree {
my ($dir,$relative,$manifest,$recursive)=@_;
for my $name (entries($dir)) {
fail('"'"'Reserved directory entry cannot be mutated'"'"') if $name eq '"'"'.zfs'"'"'||$name=~/^\.zfsm-/;
my $child=openat($dir,$name,O_PATH_LINUX|O_NOFOLLOW,0); my @s=stat($child);
$child=openat($child,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0) if S_ISDIR($s[2]);
# Non-directories (including links, FIFOs, sockets and device nodes)
# are pinned only with O_PATH|O_NOFOLLOW; never opened for I/O.
fail('"'"'Directory is not empty; recursive deletion is required'"'"') unless $recursive;
my $p=$relative eq '"'"''"'"'?$name:$relative.'"'"'/'"'"'.$name;
# Names preserve the preflight entry set. Child identities are pinned
# freshly immediately before their individual private claims.
$manifest->{$p}={directory=>S_ISDIR($s[2])?1:0} if $manifest;
inspect_tree($child,$p,$manifest,$recursive) if S_ISDIR($s[2]);
}
}
sub remove_tree {
my ($dir,$slot,$relative,$manifest)=@_;
for my $name (entries($dir)) {
my $p=$relative eq '"'"''"'"'?$name:$relative.'"'"'/'"'"'.$name; my $known=$manifest->{$p};
fail('"'"'Directory contents changed; remaining objects retained'"'"') unless $known;
# Selected-root authorization covers current contents. Pin each
# current child through capture/verification, preventing inode reuse
# without retaining one descriptor for every file in a large tree.
my $current=openat($dir,$name,O_PATH_LINUX|O_NOFOLLOW,0);
my @current_stat=stat($current);
fail('"'"'Directory entry type changed; remaining objects retained'"'"') if !!S_ISDIR($current_stat[2]) != !!$known->{directory};
my ($child,$captured)=capture($dir,$name,identity($current),$p);
close($current); # The verified captured handle now pins this inode.
if ($known->{directory}) { remove_tree($child,$captured,$p,$manifest); }
else {
my $raw=raw($captured);
fail('"'"'Unable to delete captured file; remaining objects retained'"'"') if syscall($nr{unlinkat},fileno($container),$raw,0)!=0;
record({event=>'"'"'deleted'"'"',slot=>$captured,path=>$p});
deletion_progress();
}
}
my $raw=raw($slot);
fail('"'"'Directory received new files or left its boundary; remaining objects retained'"'"') if syscall($nr{unlinkat},fileno($container),$raw,512)!=0;
record({event=>'"'"'deleted'"'"',slot=>$slot,path=>$relative});
deletion_progress();
}
sub request_root {
my ($args)=@_; my $root_path=$args->{root}; fail('"'"'Invalid dataset root'"'"') unless defined($root_path)&&!ref($root_path)&&$root_path=~m{^/[^/]}&&$root_path!~/\0/&&$root_path!~m{/$};
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to pin filesystem root'"'"');
my $dir=openat($slash,substr($root_path,1),O_RDONLY|O_DIRECTORY,1);
fail('"'"'The filesystem root cannot be a dataset mutation root'"'"') if same(identity($dir),identity($slash));
fail('"'"'Dataset root identity changed'"'"') unless same(identity($dir),$args->{root_identity}); return $dir;
}
# Only journal-only completed mutation containers are eligible. Never recurse.
sub cleanup_inspect {
my ($dir)=@_; my @s=stat($dir);
fail('"'"'Recovery container must be root-owned and private0700'"'"') unless $s[4]==0 && ($s[2]&07777)==0700;
my @names=entries($dir); fail('"'"'Recovery data is retained or the journal is missing; cleanup blocked'"'"') unless @names==1 && $names[0] eq '"'"'journal.jsonl'"'"';
my $file=openat($dir,'"'"'journal.jsonl'"'"',O_RDONLY|O_NOFOLLOW|O_NONBLOCK,0);my @f=stat($file);
fail('"'"'Recovery journal must be a private root-owned regular file with one link'"'"') unless S_ISREG($f[2]) && $f[4]==0 && ($f[2]&07777)==0600 && $f[3]==1 && $f[7]>0 && $f[7]<=262144;
my $bytes='"'"''"'"';while(read($file,my $chunk,16384)){ $bytes.=$chunk;fail('"'"'Recovery journal exceeds read bound'"'"') if length($bytes)>262144; }
fail('"'"'Recovery journal changed while reading'"'"') unless length($bytes)==$f[7] && same(identity($file),identity(openat($dir,'"'"'journal.jsonl'"'"',O_PATH_LINUX|O_NOFOLLOW,0)));
fail('"'"'Incomplete recovery journal'"'"') unless $bytes=~/\n\z/;
my @lines=split /\n/,$bytes;fail('"'"'Recovery journal exceeds record limit'"'"') if @lines>4096;
my (%slots,$created,$complete,$published,$op);my $id=identity($dir);
for my $line (@lines){
my $row=eval{$json->decode($line)};fail('"'"'Malformed recovery journal'"'"') unless ref($row)eq'"'"'HASH'"'"' && !defined($complete);
my $event=$row->{event}//'"'"''"'"';
if(!$created){fail('"'"'Recovery journal identity does not match container'"'"') unless $event eq '"'"'created'"'"' && same($id,$row->{container_identity});$created=1;next;}
if($event eq'"'"'intent'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Malformed recovery intent'"'"') unless $slot=~/\Aobject-[1-9][0-9]*\z/ && !exists($slots{$slot}) && ref($row->{identity})eq'"'"'HASH'"'"' && defined($row->{path}) && !ref($row->{path});
$slots{$slot}={state=>'"'"'intent'"'"',identity=>$row->{identity}};
}elsif($event eq'"'"'captured'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Unmatched recovery capture'"'"') unless ($slots{$slot}{state}//'"'"''"'"')eq'"'"'intent'"'"' && same($slots{$slot}{identity},$row->{identity});$slots{$slot}{state}='"'"'captured'"'"';
}elsif($event eq'"'"'deleted'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Unmatched recovery deletion'"'"') unless ($slots{$slot}{state}//'"'"''"'"')eq'"'"'captured'"'"';$slots{$slot}{state}='"'"'deleted'"'"';
}elsif($event eq'"'"'published'"'"'){
fail('"'"'Unmatched recovery publication'"'"') unless !$published && keys(%slots)==1 && ($slots{'"'"'object-1'"'"'}{state}//'"'"''"'"')eq'"'"'captured'"'"' && same($slots{'"'"'object-1'"'"'}{identity},$row->{identity});$published=1;$slots{'"'"'object-1'"'"'}{state}='"'"'published'"'"';
}elsif($event eq'"'"'complete'"'"'){
$op=$row->{op}//'"'"''"'"';fail('"'"'Incomplete or inconsistent recovery operation'"'"') unless keys(%slots)>0 && (($op eq'"'"'delete'"'"' && !$published && !grep{$_->{state}ne'"'"'deleted'"'"'}values%slots) || (($op eq'"'"'move'"'"'||$op eq'"'"'claim'"'"') && $published && keys(%slots)==1));$complete=1;
}else{fail('"'"'Unfinished, retained or unsupported recovery journal; cleanup blocked'"'"');}
}
fail('"'"'Recovery operation is not complete'"'"') unless $complete;
@names=entries($dir);fail('"'"'Recovery contents changed'"'"') unless @names==1 && $names[0]eq'"'"'journal.jsonl'"'"';
return {identity=>$id,journal_identity=>identity($file),journal_sha256=>Digest::SHA::sha256_hex($bytes),journal_base64=>MIME::Base64::encode_base64($bytes,'"'"''"'"'),operation=>$op,records=>scalar(@lines)};
}
sub cleanup_anchor {
my ($policy)=@_;my $name='"'"'.zfsm-maintenance'"'"';my $p=raw($name);my $created;
if(!lstat(anchor($root).'"'"'/'"'"'.$name)){
fail('"'"'Unable to create cleanup maintenance area'"'"') if syscall($nr{mkdirat},fileno($root),$p,0700)!=0;
$created=creation_handle($policy,$name);
}
my $dir=openat($root,$name,O_RDONLY|O_DIRECTORY,0);my @s=stat($dir);
fail('"'"'Cleanup maintenance area creation identity changed'"'"') if defined($created) && file_handle($dir)ne$created;
fail('"'"'Cleanup maintenance area is not trusted'"'"') unless $s[4]==0 && mount_id($dir)eq$policy->{mount_id} && (defined($created)?(($s[2]&0777)==0700 && !($s[2]&05000)):($s[2]&07777)==0700);
# Clear inherited setgid only on the authenticated newly created inode.
chmod(0700,$dir)==1 or fail('"'"'Unable to secure cleanup maintenance area'"'"') if defined($created);
$dir->sync && $root->sync or fail('"'"'Unable to flush maintenance area'"'"');return $dir;
}
sub cleanup_main {
my ($args)=@_;my $selected=$args->{path}//'"'"''"'"';
fail('"'"'Only a top-level mutation recovery container can be cleaned'"'"') unless !ref($selected) && $selected=~/\A\.zfsm-mutation-[a-f0-9]{48}\z/;
$root_path=$args->{root};$root=request_root($args);full_root($root);
my $source=openat($root,$selected,O_RDONLY|O_DIRECTORY,0);fail('"'"'Recovery container identity changed'"'"') unless same(identity($source),$args->{identity});
my $review=cleanup_inspect($source);
if($args->{op}eq'"'"'cleanup-inspect'"'"'){emit({ok=>JSON::PP::true,op=>$args->{op},%$review});return;}
fail('"'"'Fresh recovery journal digest is required'"'"') unless defined($args->{journal_sha256}) && !ref($args->{journal_sha256}) && $args->{journal_sha256}=~/\A[a-f0-9]{64}\z/ && $args->{journal_sha256}eq$review->{journal_sha256};
my $policy=prepare_boundary($root);my $area=cleanup_anchor($policy);
sysopen(my $random,'"'"'/dev/urandom'"'"',O_RDONLY) or fail('"'"'Unable to create cleanup identity'"'"');read($random,my $bytes,24)==24 or fail('"'"'Unable to read cleanup identity'"'"');close($random);
my $slot='"'"'cleanup-'"'"'.unpack('"'"'H*'"'"',$bytes);$container=$area;$container_name='"'"'.zfsm-maintenance'"'"';
fail('"'"'Recovery container changed before capture'"'"') unless renamed($root,$selected,$area,$slot);
$root->sync && $area->sync or fail('"'"'Unable to flush recovery capture'"'"');
$source_location={requested=>$selected,location=>(readlink(anchor($area))//'"'"'unavailable'"'"').'"'"'/'"'"'.$slot};
my $captured=openat($area,$slot,O_RDONLY|O_DIRECTORY,0);
if(!same(identity($captured),$review->{identity})){
my $restored=renamed($area,$slot,$root,$selected);$area->sync;$root->sync;
fail($restored?'"'"'Recovery identity changed; substituted directory restored'"'"':'"'"'Recovery identity changed; substituted directory retained in maintenance area'"'"');
}
my $fresh=eval{cleanup_inspect($captured)};
if(!$fresh || $fresh->{journal_sha256}ne$review->{journal_sha256} || !same($fresh->{journal_identity},$review->{journal_identity})){
my $restored=renamed($area,$slot,$root,$selected);$area->sync;$root->sync;
fail($restored?'"'"'Recovery contents changed; container restored'"'"':'"'"'Recovery contents changed; container retained in maintenance area'"'"');
}
# The selected inode is now beneath a pinned root0700 parent. A writer of
# the public dataset root cannot replace the two names used for unlinkat.
my $journal_name=raw('"'"'journal.jsonl'"'"');fail('"'"'Unable to remove archived journal; inspect maintenance area'"'"') if syscall($nr{unlinkat},fileno($captured),$journal_name,0)!=0;
$captured->sync or fail('"'"'Unable to flush journal removal'"'"');my $name=raw($slot);
fail('"'"'Unable to remove empty recovery container; inspect maintenance area'"'"') if syscall($nr{unlinkat},fileno($area),$name,512)!=0;
$area->sync or fail('"'"'Unable to flush cleanup'"'"');$container=undef;
emit({ok=>JSON::PP::true,op=>'"'"'cleanup'"'"',removed_identity=>$review->{identity},journal_sha256=>$review->{journal_sha256},maintenance=>'"'"'.zfsm-maintenance'"'"'});
}
sub main {
my @u=uname(); my %abi=(x86_64=>[437,258,316,263,300,301,303,138],aarch64=>[437,34,276,35,262,263,264,44]);
fail('"'"'Safe directory mutations require Linux x86_64 or aarch64'"'"') unless $^O eq '"'"'linux'"'"' && $abi{$u[4]};
@nr{qw(openat2 mkdirat renameat2 unlinkat fanotify_init fanotify_mark name_to_handle_at fstatfs)}=@{$abi{$u[4]}};
fail('"'"'Safe directory mutations require the privileged local worker'"'"') unless $>==0;
fail('"'"'Unexpected command arguments'"'"') if @ARGV;
umask(0077); binmode(STDIN);binmode(STDOUT); $|=1;
my $raw='"'"''"'"'; while(read(STDIN,my $chunk,65536)) { $raw.=$chunk;fail('"'"'Request too large'"'"') if length($raw)>1048576; }
my $args=$json->decode($raw); fail('"'"'Invalid request'"'"') unless ref($args) eq '"'"'HASH'"'"';
my $op=$args->{op}//'"'"''"'"'; if ($op eq '"'"'probe'"'"') { probe($args);return; }
if($op eq '"'"'cleanup-inspect'"'"'||$op eq '"'"'cleanup'"'"'){cleanup_main($args);return;}
fail('"'"'Unknown directory operation'"'"') unless $op eq '"'"'move'"'"'||$op eq '"'"'claim'"'"'||$op eq '"'"'delete'"'"';
my $selected=path($args->{path}); my $destination;
$destination=path($args->{destination}) if $op ne '"'"'delete'"'"';
fail('"'"'Destination cannot be inside selected directory'"'"') if defined($destination)&&($destination eq $selected||index($destination,$selected.'"'"'/'"'"')==0);
$root_path=$args->{root}; $root=request_root($args); $boundary=prepare_boundary($root);
my ($from,$name)=parent($selected); my $source=openat($from,$name,O_RDONLY|O_DIRECTORY,0);
fail('"'"'Selected directory identity changed'"'"') unless same(identity($source),$args->{identity});
my ($to,$target); if ($op ne '"'"'delete'"'"') {
($to,$target)=parent($destination);
fail('"'"'Destination already exists'"'"') if lstat(anchor($to).'"'"'/'"'"'.raw($target));
}
emit({phase=>'"'"'scanning'"'"'}) if $op eq '"'"'delete'"'"';
my %manifest; inspect_tree($source,'"'"''"'"',$op eq '"'"'delete'"'"'?\%manifest:undef,$op eq '"'"'delete'"'"'?($args->{recursive}?1:0):1);
$delete_total=1+scalar(keys %manifest) if $op eq '"'"'delete'"'"';
container();
fail('"'"'Selected directory parent changed; reopen the directory'"'"') unless canonical_parent($selected,$from);
my ($claimed,$slot)=capture($from,$name,$args->{identity},$selected);
my $source_canonical=eval { canonical_parent($selected,$from) };
fail('"'"'Selected directory parent moved during claim; reviewed directory retained in recovery container'"'"') unless $source_canonical;
if ($op ne '"'"'delete'"'"') {
my $destination_canonical=eval { canonical_parent($destination,$to) };
if (!$destination_canonical) {
my $restored=renamed($container,$slot,$from,$name);
$container->sync && $from->sync or fail('"'"'Unable to flush move rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$selected});
fail($restored?'"'"'Destination parent changed; original restored'"'"':'"'"'Destination parent changed; reviewed directory retained in recovery container'"'"');
}
if (!renamed($container,$slot,$to,$target)) {
my $restored=renamed($container,$slot,$from,$name);
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$selected});
fail($restored?'"'"'Move destination changed or left dataset; original restored'"'"':'"'"'Move refused; selected directory retained in recovery container'"'"');
}
$publication={requested=>$destination,handle=>$claimed};
$container->sync && $to->sync or fail('"'"'Unable to flush move; inspect destination and recovery journal'"'"');
my $actual=readlink(anchor($claimed));
record({event=>'"'"'published'"'"',destination=>$destination,location=>defined($actual)?$actual:'"'"'unavailable'"'"',identity=>identity($claimed)});
my $canonical=eval {
my $current=openat($root,$destination,O_PATH_LINUX|O_NOFOLLOW,0);
canonical_parent($destination,$to) && same(identity($current),identity($claimed));
};
fail('"'"'Destination moved during publication; reviewed directory retained at its reported actual location'"'"') unless $canonical;
} else {
# Each recursion root is already captured under the authenticated
# root0700 parent. Parent DAC prevents ordinary writers moving it out,
# including through old FDs. Uncaptured preflight handles are not used.
$bulk=1;
eval { remove_tree($claimed,$slot,'"'"''"'"',\%manifest); 1 } or do { my $error=$@; $bulk=0; my $flushed=$journal->sync && $container->sync; $error=~s/\s+$//; die $error.($flushed?'"'"''"'"':'"'"'; the recovery journal could not be flushed, inspect the recovery container'"'"')."\n"; };
$bulk=0;
$journal->sync && $container->sync or fail('"'"'Unable to flush deletion'"'"');
}
record({event=>'"'"'complete'"'"',op=>$op});
emit({ok=>JSON::PP::true,op=>$op,recovery=>recovery(),($op eq '"'"'delete'"'"'?(removed=>$delete_removed,total=>$delete_total):())});
}
unless (caller) {
$0='"'"'zfs-director folder helper'"'"';
eval { main();1 } or do {my $error=$@||'"'"'Directory mutation failed'"'"';$error=~s/\s+$//;print STDERR $json->encode({error=>$error,recovery=>recovery()})."\n";exit 1;};
}
1;
' <<< '{"op":"probe"}'; then
echo 'ZFS Director directory prerequisites unavailable; installed code and worker unchanged.' >&2
exit 1
fi
# A refused or failed update keeps the installed version; its services must not stay stopped.
runtime_stop=/var/tmp/zfs.master.ui/jobs/stop
stopped_by_installer=0
if [[ ! -e "$runtime_stop" && ! -L "$runtime_stop" ]]; then stopped_by_installer=1; fi
resume_runtime() {
[[ "$stopped_by_installer" == 1 ]] || return 0
flock -u 6 2>/dev/null || true; flock -u 7 2>/dev/null || true; flock -u 9 2>/dev/null || true
exec 6>&- 7>&- 9>&- || true
if [[ -f "$runtime_stop" && ! -L "$runtime_stop" ]]; then rm -f -- "$runtime_stop"; fi
if [[ -f "$live/scripts/zfsm-worker.php" ]]; then /usr/bin/php "$live/scripts/zfsm-worker.php" --launch >/dev/null 2>&1 || true; fi
echo 'ZFS Director: the installed version keeps running; its background services were restarted.'
}
early_failure() { local result=$?; if [[ "$result" != 0 ]]; then resume_runtime; fi; }
trap early_failure EXIT
https://github.com/d1-m4ss/zfs-director/releases/download/2026.11.13.3/zfs-director-2026.11.13.3.tgz
626b96ff57ddb34dacf28ae8d46a4a71
set -euo pipefail
# Plugin Manager displays stdout only; keep actionable errors in its dialog.
exec 2>&1
trap 'status=$?; echo "ZFS Director was NOT updated (exit $status). Nothing was changed: your settings, schedules and data are intact. The reason is shown above."; exit "$status"' ERR
umask 077
archive=/boot/config/plugins/zfs.master.ui/zfs-director-2026.11.13.3.tgz
live=/usr/local/emhttp/plugins/zfs.master.ui
stage=/usr/local/emhttp/plugins/.zfs-director-stage-2026.11.13.3-$$
previous=/usr/local/emhttp/plugins/.zfs-director-previous-2026.11.13.3-$$
printf '%s %s\n' 'ab5339f5398e20edf41cfb3769e98db0db25f56a2e28589a270e5ee140ad7c2d' "$archive" | sha256sum -c -
# Legacy migration guard. Inspect now; cleanup runs after package validation.
registration=zfs-director.plg
legacy_backup=/boot/config/plugins-removed/zfs-director-2026.11.13.3-$(date -u +%Y%m%dT%H%M%SZ)-$$
legacy_cleanup() {
/usr/bin/php -r '
// Embedded in the PLG: only known plugin code/registrations are replaced.
// Director settings, journals and recovery never enter the cleanup list.
function zfsdLegacyPath(string $path,bool $registration=false): void {
if(!str_starts_with($path,'"'"'/'"'"')||preg_match('"'"'~(?:^|/)\.\.?(/|$)~'"'"',$path))throw new RuntimeException('"'"'Unsafe installer path'"'"');
for($parent=dirname($path);$parent!=='"'"'/'"'"';$parent=dirname($parent)){
if(is_link($parent)||(file_exists($parent)&&!is_dir($parent)))throw new RuntimeException('"'"'Unsafe installer parent: '"'"'.$parent);
}
if(is_link($path)){
if(!$registration||readlink($path)!=='"'"'/boot/config/plugins/'"'"'.basename($path))throw new RuntimeException('"'"'Unsafe plugin link: '"'"'.$path);
}elseif(file_exists($path)&&!is_file($path)&&!is_dir($path))throw new RuntimeException('"'"'Unsafe installer object: '"'"'.$path);
}
function zfsdLegacyPlan(string $root,string $keep): array {
if(!in_array($keep,['"'"'zfs-director.plg'"'"','"'"'zfs.master.ui.plg'"'"','"'"'zfs.master.ui.local.plg'"'"'],true))throw new RuntimeException('"'"'Unknown installer registration'"'"');
$root=rtrim($root,'"'"'/'"'"');$moves=[];$cron=[];
zfsdLegacyPath($root.'"'"'/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director'"'"');
$mounts=@file_get_contents($root.'"'"'/proc/self/mountinfo'"'"');
if($mounts===false&&$root==='"'"''"'"')throw new RuntimeException('"'"'Cannot verify legacy directory mount boundaries'"'"');
foreach(explode("\n",(string)$mounts)as$line){
$fields=explode('"'"' '"'"',$line);if(count($fields)<6)continue;
$mount=preg_replace_callback('"'"'/\\\\([0-7]{3})/'"'"',static fn($m)=>chr(octdec($m[1])),$fields[4]);
foreach(['"'"'/usr/local/emhttp/plugins/zfs.master'"'"','"'"'/usr/local/emhttp/plugins/zfs.master.ui'"'"','"'"'/boot/config/plugins/zfs.master'"'"']as$tree){
$tree=$root.$tree;if($mount===$tree||str_starts_with($mount,$tree.'"'"'/'"'"'))throw new RuntimeException('"'"'Mounted data inside a plugin directory requires inspection: '"'"'.$mount);
}
}
foreach(['"'"'zfs.master.plg'"'"','"'"'zfs-director.plg'"'"','"'"'zfs.master.ui.plg'"'"','"'"'zfs.master.ui.local.plg'"'"']as$name){
if($name===$keep)continue;
foreach(['"'"'/boot/config/plugins/'"'"','"'"'/var/log/plugins/'"'"','"'"'/boot/config/plugins/zfs.master.ui/'"'"']as$folder){
$p=$root.$folder.$name;zfsdLegacyPath($p,$folder==='"'"'/var/log/plugins/'"'"');
if(is_dir($p)&&!is_link($p))throw new RuntimeException('"'"'Plugin registration is a directory: '"'"'.$p);
if(file_exists($p)||is_link($p))$moves[]=$p;
}
}
foreach(['"'"'/usr/local/emhttp/plugins/zfs.master'"'"','"'"'/boot/config/plugins/zfs.master'"'"']as$folder){
$p=$root.$folder;zfsdLegacyPath($p);
if(is_link($p)||(file_exists($p)&&!is_dir($p)))throw new RuntimeException('"'"'Unsafe legacy plugin directory: '"'"'.$p);
if(is_dir($p)){
if(glob($p.'"'"'/*.cron'"'"'))throw new RuntimeException('"'"'Legacy ZFS Master has custom schedules; inspect them before replacing its configuration'"'"');
$moves[]=$p;
}
}
// Obsolete packages are code, not user state. Retain them in the same
// rollback backup instead of leaving old installers in the state folder.
foreach(glob($root.'"'"'/boot/config/plugins/zfs.master.ui/zfs.master.ui-*.tgz'"'"')?:[]as$p){
if(!preg_match('"'"'/^zfs\.master\.ui-[0-9.]+\.tgz$/D'"'"',basename($p)))continue;
zfsdLegacyPath($p);if(!is_file($p)||is_link($p))throw new RuntimeException('"'"'Unsafe legacy package: '"'"'.$p);$moves[]=$p;
}
foreach(['"'"'zfs-director'"'"','"'"'zfs.master.ui'"'"','"'"'zfs.master.ui.local'"'"']as$namespace){
$p=$root.'"'"'/boot/config/plugins/'"'"'.$namespace.'"'"'/protection.cron'"'"';zfsdLegacyPath($p);
foreach(glob(dirname($p).'"'"'/*.cron'"'"')?:[]as$other)if($other!==$p)throw new RuntimeException('"'"'Custom schedules in an old Director namespace require inspection: '"'"'.$other);
if(is_link($p)||(file_exists($p)&&(!is_file($p)||filesize($p)>1048576)))throw new RuntimeException('"'"'Unsafe Director cron source: '"'"'.$p);
if(is_file($p))$cron[$p]=file_get_contents($p);
}
$destination=$root.'"'"'/boot/config/plugins/'"'"'.basename($keep,'"'"'.plg'"'"').'"'"'/protection.cron'"'"';
$selected=null;$active=[];
foreach($cron as$p=>$text){
$lines=array_values(array_filter(explode("\n",$text),static fn($line)=>trim($line)!=='"'"''"'"'&&!str_starts_with(ltrim($line),'"'"'#'"'"')));
if($lines)$active[$p]=$text;
}
if(count(array_unique($active))>1)throw new RuntimeException('"'"'Conflicting Director schedules; no registration or schedule was removed'"'"');
if($active)$selected=array_key_first($active);
elseif(isset($cron[$destination]))$selected=$destination;
elseif($cron)$selected=array_key_first($cron);
foreach($cron as$p=>$text)if($p!==$destination)$moves[]=$p;
// A different empty destination may be replaced by the existing active
// source, but is backed up just like every displaced registration.
if($selected!==null&&$selected!==$destination&&isset($cron[$destination]))$moves[]=$destination;
return ['"'"'moves'"'"'=>array_values(array_unique($moves)),'"'"'cron_target'"'"'=>$destination,'"'"'cron_text'"'"'=>$selected!==null?$cron[$selected]:null,
'"'"'write_cron'"'"'=>$selected!==null&&($cron[$destination]??null)!==$cron[$selected]];
}
function zfsdLegacyProcesses(string $root): array {
$nchan=[];$legacy=rtrim($root,'"'"'/'"'"').'"'"'/usr/local/emhttp/plugins/zfs.master/'"'"';
foreach(glob(rtrim($root,'"'"'/'"'"').'"'"'/proc/[0-9]*/cmdline'"'"')?:[]as$file){
$args=explode("\0",(string)@file_get_contents($file));$matched=false;
foreach($args as$arg)if(str_starts_with($arg,$legacy))$matched=true;
if(!$matched)continue;
$script=$args[1]??'"'"''"'"';if($script==='"'"'-q'"'"')$script=$args[2]??'"'"''"'"';
if(basename($args[0])!=='"'"'php'"'"'||$script!==$legacy.'"'"'nchan/zfs_master'"'"')throw new RuntimeException('"'"'An operation from ZFS Master is still running; finish it before installation'"'"');
$nchan[(int)basename(dirname($file))]=(string)file_get_contents($file);
}
return $nchan;
}
function zfsdLegacyInstall(string $mode,string $root,string $keep,string $backup): void {
$root=rtrim($root,'"'"'/'"'"');
if($mode==='"'"'check'"'"'){zfsdLegacyPlan($root,$keep);zfsdLegacyProcesses($root);return;}
zfsdLegacyPath($backup);
if($mode==='"'"'rollback'"'"'){
$journal=$backup.'"'"'/migration.json'"'"';if(!is_file($journal))return;
$state=json_decode(file_get_contents($journal),true,64,JSON_THROW_ON_ERROR);
if(!empty($state['"'"'rolled_back'"'"']))return;
if(isset($state['"'"'created_cron'"'"'])){
[$target,$sha]=$state['"'"'created_cron'"'"'];zfsdLegacyPath($target);
if(file_exists($target)){
$restored=false;$current=lstat($target);
foreach($state['"'"'done'"'"']as$item)if($item[0]===$target&&[$current['"'"'dev'"'"'],$current['"'"'ino'"'"']]===$item[3]&&!file_exists($item[1]))$restored=true;
if(!$restored){
if(is_link($target)||!is_file($target)||hash_file('"'"'sha256'"'"',$target)!==$sha)throw new RuntimeException('"'"'Cron changed after installation; retained backup: '"'"'.$backup);
if(!unlink($target))throw new RuntimeException('"'"'Cannot undo Director cron relocation'"'"');
}
}
}
foreach(array_reverse($state['"'"'done'"'"'])as$item){
[$from,$to,$link,$identity]=$item;clearstatcache();
if(file_exists($from)||is_link($from)){
$stat=lstat($from);
if(($link!==null&&is_link($from)&&readlink($from)===$link)||($link===null&&[$stat['"'"'dev'"'"'],$stat['"'"'ino'"'"']]===$identity&&!file_exists($to)))continue;
throw new RuntimeException('"'"'Rollback target changed; retained backup: '"'"'.$backup);
}
if($link!==null){if(!symlink($link,$from))throw new RuntimeException('"'"'Cannot restore plugin registration'"'"');}
elseif(!rename($to,$from))throw new RuntimeException('"'"'Cannot restore displaced plugin file'"'"');
}
$state['"'"'rolled_back'"'"']=true;
if(file_put_contents($journal.'"'"'.tmp'"'"',json_encode($state,JSON_THROW_ON_ERROR),LOCK_EX)===false||!rename($journal.'"'"'.tmp'"'"',$journal))throw new RuntimeException('"'"'Cannot record completed rollback'"'"');
return;
}
if($mode!=='"'"'apply'"'"')throw new RuntimeException('"'"'Unknown legacy cleanup action'"'"');
$plan=zfsdLegacyPlan($root,$keep);$processes=zfsdLegacyProcesses($root);
if($processes&&$root!=='"'"''"'"')throw new RuntimeException('"'"'Process changes require the live system root'"'"');
foreach($processes as$pid=>$cmd){
if((string)@file_get_contents('"'"'/proc/'"'"'.$pid.'"'"'/cmdline'"'"')!==$cmd)throw new RuntimeException('"'"'Legacy monitor identity changed'"'"');
if(!posix_kill($pid,15))throw new RuntimeException('"'"'Unable to stop the legacy ZFS Master monitor'"'"');
}
$deadline=microtime(true)+5;
do{$running=zfsdLegacyProcesses($root);if(!$running)break;usleep(100000);}while(microtime(true)<$deadline);
if($running)throw new RuntimeException('"'"'Legacy ZFS Master monitor has not stopped'"'"');
if(!$plan['"'"'moves'"'"']&&!$plan['"'"'write_cron'"'"'])return;
if(file_exists($backup)||!mkdir($backup,0700,true))throw new RuntimeException('"'"'Cannot create legacy plugin backup'"'"');
$state=['"'"'done'"'"'=>[]];$journal=$backup.'"'"'/migration.json'"'"';
$save=static function()use(&$state,$journal){$json=json_encode($state,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);if(file_put_contents($journal.'"'"'.tmp'"'"',$json,LOCK_EX)!==strlen($json)||!rename($journal.'"'"'.tmp'"'"',$journal))throw new RuntimeException('"'"'Cannot save installer rollback journal'"'"');};
$save();
try{
foreach($plan['"'"'moves'"'"']as$i=>$from){
$link=is_link($from)?readlink($from):null;
$to=is_dir($from)&&str_starts_with($from,$root.'"'"'/usr/local/emhttp/plugins/'"'"')?dirname($from).'"'"'/.zfs-director-legacy-'"'"'.basename($backup).'"'"'-'"'"'.$i:$backup.'"'"'/item-'"'"'.$i;
if(file_exists($to)||is_link($to))throw new RuntimeException('"'"'Legacy backup destination exists'"'"');
$stat=lstat($from);$state['"'"'done'"'"'][]=[$from,$to,$link,[$stat['"'"'dev'"'"'],$stat['"'"'ino'"'"']]];$save();
if($link!==null){if(!unlink($from))throw new RuntimeException('"'"'Cannot remove old registration'"'"');}
elseif(!rename($from,$to))throw new RuntimeException('"'"'Cannot retain old plugin file'"'"');
}
if($plan['"'"'write_cron'"'"']){
$target=$plan['"'"'cron_target'"'"'];if(!is_dir(dirname($target))&&!mkdir(dirname($target),0700,true))throw new RuntimeException('"'"'Cannot preserve Director cron namespace'"'"');
if(file_exists($target)||is_link($target))throw new RuntimeException('"'"'Director cron changed during installation'"'"');
$text=$plan['"'"'cron_text'"'"'];$state['"'"'created_cron'"'"']=[$target,hash('"'"'sha256'"'"',$text)];$save();
$temporary=tempnam(dirname($target),'"'"'.director-cron-'"'"');
if($temporary===false)throw new RuntimeException('"'"'Cannot stage Director cron'"'"');
try{if(file_put_contents($temporary,$text,LOCK_EX)!==strlen($text)||!rename($temporary,$target)||file_get_contents($target)!==$text)throw new RuntimeException('"'"'Director cron readback failed'"'"');}
finally{if(is_file($temporary))unlink($temporary);}
}
echo '"'"'ZFS Director: old ZFS Master code and duplicate registrations removed; rollback backup: '"'"'.$backup."\n";
}catch(Throwable$error){
zfsdLegacyInstall('"'"'rollback'"'"',$root,$keep,$backup);throw $error;
}
}
try { zfsdLegacyInstall($argv[1], $argv[2], $argv[3], $argv[4]); } catch (Throwable $error) { echo "ZFS Director: ".$error->getMessage()."\n"; exit(1); }' -- "$1" / "$registration" "$legacy_backup"
}
legacy_cleanup check
for directory in "$live" /boot/config/plugins/zfs.master.ui/protection; do
if [[ -L "$directory" || ( -e "$directory" && ! -d "$directory" ) ]]; then echo "ZFS Director: unsafe installer directory: $directory"; exit 1; fi
done
for object in /var/lock/zfs.master.ui.lock /boot/config/plugins/zfs.master.ui/protection/config.lock /boot/config/plugins/zfs.master.ui/protection/execution.lock; do
if [[ -L "$object" || ( -e "$object" && ! -f "$object" ) ]]; then echo "ZFS Director: unsafe installer lock: $object"; exit 1; fi
done
hook=/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director
if [[ -L "$hook" || ( -e "$hook" && ! -f "$hook" ) ]]; then echo 'ZFS Director: unsafe Plugin Manager hook'; exit 1; fi
if [[ -f "$hook" && "$(sha256sum "$hook" | cut -d ' ' -f 1)" != 'f16a1c7f2e5fd19ec09bc9eb6e7c2b396fa28253e3269f9e31d08d10237cb3c3' ]]; then echo 'ZFS Director: existing post-hook has unknown ownership; retained unchanged'; exit 1; fi
# End legacy migration guard.
# Runtime stop marker guard.
stop_runtime() {
/usr/bin/perl -e 'use strict;
use warnings;
use Fcntl qw(:DEFAULT :mode);
my ($base, $owner, $message) = @ARGV;
die "Invalid runtime guard arguments\n" unless defined($message) && $owner =~ /\A[0-9]+\z/ && $base =~ m{\A/} && $base !~ m{(?:\A|/)\.\.?(/|\z)};
sub enter_directory {
my ($name, $uid, $shared) = @_;
sysopen(my $directory, $name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) or die "Runtime directory requires inspection\n";
my @stat = stat($directory);
die "Untrusted runtime directory\n" unless @stat && S_ISDIR($stat[2]) && $stat[4] == $uid
&& (!(($stat[2]) & 0022) || ($shared && ($stat[2] & 01000)));
chdir($directory) or die "Unable to enter trusted runtime directory\n";
}
enter_directory('"'"'/'"'"', 0, 0);
my @parents = grep { length } split m{/}, $base;
for my $index (0 .. $#parents) {
enter_directory($parents[$index], $index == $#parents ? $owner : 0, 1);
}
for my $name ('"'"'zfs.master.ui'"'"', '"'"'jobs'"'"') {
if (!lstat($name)) { mkdir($name, 0700) or die "Unable to create runtime directory\n"; }
enter_directory($name, $owner, 0);
}
my @stop = lstat('"'"'stop'"'"');
die "Stop marker requires inspection\n" if @stop && (!S_ISREG($stop[2]) || $stop[4] != $owner || ($stop[2] & 0022) || $stop[3] != 1);
my $temporary = '"'"'.stop-'"'"' . $$ . '"'"'-'"'"' . int(rand(1000000000));
sysopen(my $marker, $temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600) or die "Unable to create stop marker\n";
eval {
print {$marker} $message, "\n" or die "Unable to write stop marker\n";
close($marker) or die "Unable to close stop marker\n";
rename($temporary, '"'"'stop'"'"') or die "Unable to publish stop marker\n";
1;
} or do {
my $error = $@;
close($marker) if defined(fileno($marker));
unlink($temporary);
die $error;
};
' -- /var/tmp 0 "$1"
}
# End runtime stop marker guard.
# Boot reconstruction restores the UI even when recovery must stay stopped.
installed=/boot/config/plugins/zfs.master.ui/installed.sha256
[[ ! -L "$installed" && ( ! -e "$installed" || -f "$installed" ) ]]
restore_accepted=0
hold_recovery=0
old_receipt=
if [[ -f "$installed" ]]; then old_receipt=$(cat "$installed"); fi
if [[ ! -e "$live" && -f "$installed" && "$(cat "$installed")" == 'ab5339f5398e20edf41cfb3769e98db0db25f56a2e28589a270e5ee140ad7c2d' ]]; then restore_accepted=1; fi
preflight() {
local status=0 mode=install
if [[ "$restore_accepted" == 1 ]]; then mode=accepted-boot; fi
/usr/bin/php -r '
// Read-only installer prerequisite. No plugin includes, worker or scheduler calls.
if(PHP_SAPI!=='"'"'cli'"'"')exit(2);
$root=$argv[1]??'"'"'/boot/config/plugins/zfs.master.ui'"'"';
$acceptedBoot=($argv[2]??'"'"''"'"')==='"'"'accepted-boot'"'"';
$managedInstall=($argv[2]??'"'"''"'"')==='"'"'install'"'"';
$requireRecovery=static function($message)use($acceptedBoot){fwrite(STDERR,'"'"'ZFS Director preflight: '"'"'.$message."\n");exit($acceptedBoot?10:1);};
try{
if(!is_string($root)||!str_starts_with($root,'"'"'/'"'"')||str_contains($root,"\0")||str_contains($root,'"'"'..'"'"')||is_link($root))throw new RuntimeException('"'"'Unsafe persistent state path'"'"');
$config=$root.'"'"'/zfs.master.ui.cfg'"'"';
if(file_exists($config)||is_link($config)){
if(is_link($config)||!is_file($config)||filesize($config)>1048576)throw new RuntimeException('"'"'Settings require inspection'"'"');
$parsed=@parse_ini_file($config,true,INI_SCANNER_RAW);if(!is_array($parsed))$requireRecovery('"'"'Settings cannot be parsed; restore stopped for inspection'"'"');
$flag=$parsed['"'"'general'"'"']['"'"'sanoid_integration'"'"']??'"'"'0'"'"';if((string)$flag!=='"'"'0'"'"'&&!(($acceptedBoot||$managedInstall)&&(string)$flag==='"'"'1'"'"'))$requireRecovery('"'"'Integration settings require inspection before upgrade'"'"');
}
$jobs=$root.'"'"'/jobs'"'"';if(is_link($jobs))throw new RuntimeException('"'"'Jobs path requires inspection'"'"');
$pendingJobs=[];
$scanJobs=function($directory,$skipIfDurable=null)use(&$pendingJobs,$requireRecovery){
if(!is_dir($directory))return;
$scan=opendir($directory);$count=0;
// Only job journals count toward the bound: every journal also keeps a .lock file and may
// sit next to other files, so counting raw directory entries refused healthy installs.
try{while(($name=readdir($scan))!==false){if(!preg_match('"'"'/^[0-9]{8}-[0-9]{6}-[a-f0-9]{12}\.json$/D'"'"',$name))continue;if(++$count>50000)$requireRecovery('"'"'Jobs scan incomplete: more than 50000 job journals; restore stopped for inspection'"'"');
// A promotion can crash between writing the durable copy and removing this leftover
// (REL-002); once a durable record exists for this id, it alone is authoritative.
if($skipIfDurable!==null&&is_file($skipIfDurable.'"'"'/'"'"'.$name))continue;
$file=$directory.'"'"'/'"'"'.$name;
if(is_link($file)||!is_file($file)||filesize($file)>131072)throw new RuntimeException('"'"'Job requires inspection before upgrade'"'"');$j=json_decode(file_get_contents($file),true,32);if(!is_array($j))$requireRecovery('"'"'Malformed job requires inspection'"'"');
if(!is_array($j)||!is_string($j['"'"'state'"'"']??null))$requireRecovery('"'"'A job with an unreadable state requires inspection; background services stay paused.'"'"');
if(in_array($j['"'"'state'"'"'],['"'"'queued'"'"','"'"'running'"'"'],true)){$label=trim(preg_replace('"'"'/[^\x20-\x7e]+/'"'"','"'"''"'"',(string)($j['"'"'type'"'"']??'"'"'job'"'"').'"'"' '"'"'.(string)($j['"'"'source'"'"']??$j['"'"'label'"'"']??'"'"''"'"')));$pendingJobs[]=substr($label==='"'"''"'"'?'"'"'job'"'"':$label,0,90).'"'"' ('"'"'.$j['"'"'state'"'"'].'"'"')'"'"';continue;}
// Terminal file jobs can still release holds or delete ZIP staging.
// Future expiry/retry times only defer that work after activation.
if(in_array($j['"'"'type'"'"']??null,['"'"'restore'"'"','"'"'archive'"'"'],true)&&($j['"'"'hold_owned'"'"']??null)!==false)$requireRecovery('"'"'File job protection requires inspection; worker stays stopped'"'"');
if(($j['"'"'type'"'"']??null)==='"'"'archive'"'"'&&($j['"'"'archive_removed'"'"']??null)!==true)$requireRecovery('"'"'Retained ZIP cleanup requires inspection; worker stays stopped'"'"');
}}finally{closedir($scan);}
};
$scanJobs($jobs);
// A scheduled policy check (REL-002) is queued only in the tmpfs runtime directory while
// idle-capable; an update must refuse to proceed while one is in flight there too.
$runtimeJobs='"'"'/var/tmp/'"'"'.basename($root).'"'"'/jobs/protection-queue'"'"';if(is_link($runtimeJobs))throw new RuntimeException('"'"'Jobs path requires inspection'"'"');
$scanJobs($runtimeJobs,$jobs);
if($pendingJobs){
$shown=implode('"'"'; '"'"',array_slice($pendingJobs,0,3)).(count($pendingJobs)>3?'"'"'; and '"'"'.(count($pendingJobs)-3).'"'"' more'"'"':'"'"''"'"');
$requireRecovery('"'"'Update paused: '"'"'.count($pendingJobs).'"'"' job(s) still queued or running: '"'"'.$shown.'"'"'. Nothing was changed and the installed version keeps running. Next: open Main > ZFS Director > Jobs, wait for these jobs to finish or cancel them, then run Plugins > Check for Updates again.'"'"');
}
echo "ZFS Director preflight: no pending execution or file cleanup. Settings and schedules retained.\n";
}catch(Throwable$e){fwrite(STDERR,'"'"'ZFS Director upgrade blocked: '"'"'.$e->getMessage()."\n");exit(1);}
' -- /boot/config/plugins/zfs.master.ui "$mode" || status=$?
if [[ "$status" == 10 && "$restore_accepted" == 1 ]]; then hold_recovery=1; elif [[ "$status" != 0 ]]; then return "$status"; fi
}
preflight
# Read-only prerequisite gate, before stopping the installed implementation.
# Execute the exact packaged helper; the generic probe creates no files.
if ! /usr/bin/perl -e '#!/usr/bin/perl
# Linux directory mutations. Every destructive name is first captured into a
# root-owned private directory and checked by dev/ino. A complete filesystem
# root confines renames; FAN_CREATE handles authenticate the private parent.
use strict;
use warnings;
use JSON::PP;
use Fcntl qw(:DEFAULT :mode);
use POSIX qw(uname);
use Encode qw(encode decode FB_CROAK LEAVE_SRC);
use IO::Handle;
use Errno qw(EBADF);
use Digest::SHA ();
use MIME::Base64 ();
use Time::HiRes ();
my $json=JSON::PP->new->utf8->canonical;
my %nr;
my ($root,$container,$container_name,$root_path,$journal,$source_location,$publication,$boundary);
my $serial=0;
my ($delete_total,$delete_removed,$delete_tick)=(0,0,0);
# Set while emptying a tree already captured in the private container: names
# are still claimed one by one, but durability is flushed once at the end
# instead of fsyncing the journal and directories for every file.
my $bulk=0;
sub deletion_progress {
$delete_removed++;
if (Time::HiRes::time()-$delete_tick>=0.5||$delete_removed==$delete_total) {
emit({phase=>'"'"'deleting'"'"',removed=>$delete_removed,total=>$delete_total});$delete_tick=Time::HiRes::time();
}
}
use constant O_PATH_LINUX => 0x200000;
use constant O_CLOEXEC_LINUX => 0x80000;
sub fail { die $_[0]."\n" }
sub raw { encode('"'"'UTF-8'"'"',$_[0],FB_CROAK|LEAVE_SRC) }
sub emit { print $json->encode($_[0])."\n" or fail('"'"'Progress channel closed'"'"'); STDOUT->flush; }
sub identity { my @s=stat($_[0]); fail('"'"'Pinned object unavailable'"'"') unless @s; return {dev=>"$s[0]",ino=>"$s[1]"}; }
sub same { my ($a,$b)=@_; return ref($b) eq '"'"'HASH'"'"' && defined($b->{dev}) && defined($b->{ino}) && $a->{dev} eq "$b->{dev}" && $a->{ino} eq "$b->{ino}"; }
sub path {
my ($value)=@_; fail('"'"'Invalid directory path'"'"') if !defined($value)||ref($value)||length($value)>4096||$value=~m{^/|\0};
my @parts=split m{/},$value,-1;
fail('"'"'Invalid directory path'"'"') if !@parts||@parts>128||grep { $_ eq '"'"''"'"'||$_ eq '"'"'.'"'"'||$_ eq '"'"'..'"'"'||$_ eq '"'"'.zfs'"'"'||/^\.zfsm-/ } @parts;
return $value;
}
sub openat {
my ($parent,$name,$flags,$allow_mount)=@_;
my $how=pack('"'"'Q<Q<Q<'"'"',$flags,0,8|4|($allow_mount?0:1)); my $p=raw($name);
my $fd=syscall($nr{openat2},fileno($parent),$p,$how,length($how));
fail('"'"'Path is unavailable, contains a symbolic link, or crosses a mount: '"'"'.$name) if $fd<0;
open(my $fh,'"'"'<&='"'"',$fd) or fail('"'"'Unable to retain directory handle'"'"'); return $fh;
}
sub anchor { '"'"'/proc/self/fd/'"'"'.fileno($_[0]) }
sub entries {
my ($dir)=@_; opendir(my $dh,anchor($dir)) or fail('"'"'Unable to inspect pinned directory'"'"'); my @out;
while(defined(my $p=readdir($dh))) { next if $p eq '"'"'.'"'"'||$p eq '"'"'..'"'"'; push @out,decode('"'"'UTF-8'"'"',$p,FB_CROAK); fail('"'"'Directory exceeds 100000 entries'"'"') if @out>100000; }
closedir($dh); return sort @out;
}
sub renamed {
my ($from,$old,$to,$new)=@_; my $a=raw($old); my $b=raw($new);
return syscall($nr{renameat2},fileno($from),$a,fileno($to),$b,1)==0;
}
sub parent {
my ($name)=@_; my @parts=split m{/},$name; my $leaf=pop @parts;
return (openat($root,@parts?join('"'"'/'"'"',@parts):'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0),$leaf);
}
sub canonical_parent {
my ($path,$pinned)=@_;my ($current)=parent($path);
return same(identity($current),identity($pinned));
}
sub recovery {
return undef unless $container;
my $where=readlink(anchor($container));
my $details={container=>$container_name,location=>defined($where)?$where:'"'"'unavailable'"'"',identity=>identity($container),journal=>'"'"'journal.jsonl'"'"',retained=>JSON::PP::true};
$details->{source}=$source_location if $source_location;
if ($publication) {
my $actual=readlink(anchor($publication->{handle}));
$details->{publication}={requested=>$publication->{requested},location=>defined($actual)?$actual:'"'"'unavailable'"'"',identity=>identity($publication->{handle})};
}
return $details;
}
sub record {
my ($row)=@_; print $journal $json->encode($row)."\n" or fail('"'"'Unable to write recovery journal'"'"');
$journal->flush && ($bulk||$journal->sync) or fail('"'"'Unable to flush recovery journal'"'"');
}
sub mount_id {
my ($dir)=@_; open(my $info,'"'"'<'"'"','"'"'/proc/self/fdinfo/'"'"'.fileno($dir)) or fail('"'"'Unable to inspect pinned mount identity'"'"');
local $/; my $value=<$info>; close($info);
fail('"'"'Pinned mount identity unavailable'"'"') unless $value=~/^mnt_id:\s*(\d+)\s*$/m; return "$1";
}
sub full_root {
my ($dir)=@_; my $id=mount_id($dir); my $found;
open(my $info,'"'"'<'"'"','"'"'/proc/self/mountinfo'"'"') or fail('"'"'Unable to inspect filesystem mount roots'"'"');
while(my $line=<$info>) {
my @fields=split / /,$line; next unless $fields[0] eq $id;
fail('"'"'Dataset mutation root must be a complete filesystem root'"'"') unless $fields[3] eq '"'"'/'"'"';
my $point=$fields[4]; $point=~s/\\([0-7]{3})/chr(oct($1))/ge; $point=decode('"'"'UTF-8'"'"',$point,FB_CROAK);
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to inspect filesystem mount root'"'"');
my $mounted=$point eq '"'"'/'"'"'?$slash:openat($slash,substr($point,1),O_RDONLY|O_DIRECTORY,1);
fail('"'"'Dataset mutation root must be a complete filesystem root'"'"') unless same(identity($mounted),identity($dir)) && mount_id($mounted) eq $id;
$found=1; last;
}
close($info); fail('"'"'Pinned filesystem mount root unavailable'"'"') unless $found; return $id;
}
sub file_handle {
my ($dir)=@_; my $handle=pack('"'"'L<l<'"'"',128,0).("\0"x128); my $mount=pack('"'"'l<'"'"',0); my $empty='"'"''"'"';
fail('"'"'Filesystem object handles are required'"'"') if syscall($nr{name_to_handle_at},fileno($dir),$empty,$handle,$mount,0x1000)!=0;
my ($length,$type)=unpack('"'"'L<l<'"'"',$handle); fail('"'"'Invalid filesystem object handle'"'"') if !$length||$length>128;
return pack('"'"'l<'"'"',$type).substr($handle,8,$length);
}
sub filesystem_id {
my ($dir)=@_; my $value="\0"x128;
fail('"'"'Unable to inspect owning filesystem identity'"'"') if syscall($nr{fstatfs},fileno($dir),$value)!=0;
return substr($value,56,8);
}
sub fanotify {
# Directory entry notifications must include both the parent and target FID.
my $fd=syscall($nr{fanotify_init},0x1e03,O_RDONLY|O_CLOEXEC_LINUX);
fail('"'"'Safe directory mutations require fanotify target object handles'"'"') if $fd<0;
open(my $fan,'"'"'<&='"'"',$fd) or fail('"'"'Unable to retain creation notification handle'"'"'); return $fan;
}
sub prepare_boundary {
my ($dir)=@_; my $id=full_root($dir); my $handle=file_handle($dir); my $fsid=filesystem_id($dir); my $fan=fanotify(); my $dot='"'"'.'"'"';
fail('"'"'Filesystem creation notifications are required'"'"') if syscall($nr{fanotify_mark},fileno($fan),1|8,0x48000100,fileno($dir),$dot)!=0;
return {mount_id=>$id,handle=>$handle,fsid=>$fsid,fan=>$fan};
}
sub creation_handle {
my ($policy,$name)=@_; my $ready='"'"''"'"'; vec($ready,fileno($policy->{fan}),1)=1;
fail('"'"'Private directory creation notification unavailable'"'"') unless select($ready,undef,undef,2)>0;
my $bytes='"'"''"'"'; my $length=sysread($policy->{fan},$bytes,65536);
fail('"'"'Unable to read private directory creation notification'"'"') unless defined($length)&&$length>0;
my ($offset,$target)=(0,undef);
while($offset<$length) {
fail('"'"'Malformed private directory creation notification'"'"') if $length-$offset<24;
my ($event_length,$version,$reserved,$metadata_length,$mask,$fd,$pid)=unpack('"'"'L<CCS<Q<l<l<'"'"',substr($bytes,$offset,24));
fail('"'"'Invalid private directory creation notification'"'"') if $version!=3||$reserved||$metadata_length!=24||$event_length<$metadata_length||$offset+$event_length>$length||($mask&0x4000)||$fd!=-1;
my (%records,$position); $position=$offset+$metadata_length;
while($position<$offset+$event_length) {
fail('"'"'Malformed creation identity record'"'"') if $offset+$event_length-$position<20;
my ($kind,$pad,$size)=unpack('"'"'CCS<'"'"',substr($bytes,$position,4));
fail('"'"'Unexpected creation identity record'"'"') if $pad||($kind!=1&&$kind!=2)||$size<20||$position+$size>$offset+$event_length||exists($records{$kind});
my $fsid=substr($bytes,$position+4,8); my ($handle_length,$type)=unpack('"'"'L<l<'"'"',substr($bytes,$position+12,8));
fail('"'"'Invalid creation filesystem identity'"'"') if $fsid ne $policy->{fsid}||!$handle_length||$handle_length>128||20+$handle_length>$size;
my $tail=substr($bytes,$position+20+$handle_length,$size-20-$handle_length);
fail('"'"'Malformed target creation identity'"'"') if $kind==1&&$tail=~/[^\0]/;
$records{$kind}={handle=>pack('"'"'l<'"'"',$type).substr($bytes,$position+20,$handle_length),tail=>$tail};
$position+=$size;
}
fail('"'"'Missing creation identity records'"'"') unless $position==$offset+$event_length&&keys(%records)==2&&$records{1}&&$records{2};
my $tail=$records{2}{tail}; fail('"'"'Malformed created directory name'"'"') unless $tail=~/\A([^\0]*)\0\0*\z/s;
my $event_name=$1;
if ($pid==$$ && $mask==0x40000100 && $event_name eq raw($name)) {
fail('"'"'Private directory creation parent changed'"'"') unless $records{2}{handle} eq $policy->{handle};
fail('"'"'Ambiguous private directory creation identity'"'"') if defined($target); $target=$records{1}{handle};
}
$offset+=$event_length;
}
fail('"'"'Private directory creation identity unavailable'"'"') unless defined($target); return $target;
}
sub probe {
my ($args)=@_;
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to pin filesystem root'"'"');
my $pinned=openat($slash,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0);
my $a='"'"'zfsm-readonly-probe'"'"';my $b='"'"'zfsm-readonly-probe'"'"';
my $result=syscall($nr{renameat2},-1,$a,-1,$b,1);
fail('"'"'Safe directory mutations require renameat2 RENAME_NOREPLACE'"'"') unless $result<0 && $!==EBADF;
my $fan=fanotify(); my $handle=pack('"'"'L<l<'"'"',128,0).("\0"x128);my $mount=pack('"'"'l<'"'"',0);my $empty='"'"''"'"';
my $result_handle=syscall($nr{name_to_handle_at},-1,$empty,$handle,$mount,0x1000);
fail('"'"'Safe directory mutations require name_to_handle_at'"'"') unless $result_handle<0&&$!==EBADF;
my $dot='"'"'.'"'"'; my $result_mark=syscall($nr{fanotify_mark},-1,1|8,0x48000100,-1,$dot);
fail('"'"'Safe directory mutations require fanotify_mark'"'"') unless $result_mark<0&&$!==EBADF;
my $row={ok=>JSON::PP::true,op=>'"'"'probe'"'"',fanotify_target_fid=>JSON::PP::true};
if (exists($args->{root})) { my $dir=request_root($args); my $policy=prepare_boundary($dir); $row->{root_mount_id}=$policy->{mount_id}; }
emit($row);
}
sub container {
sysopen(my $random,'"'"'/dev/urandom'"'"',O_RDONLY) or fail('"'"'Unable to obtain private claim name'"'"');
read($random,my $bytes,24)==24 or fail('"'"'Unable to obtain private claim name'"'"'); close($random);
$container_name='"'"'.zfsm-mutation-'"'"'.unpack('"'"'H*'"'"',$bytes); my $name=raw($container_name);
fail('"'"'Unable to create private recovery container'"'"') if syscall($nr{mkdirat},fileno($root),$name,0700)!=0;
my $created=creation_handle($boundary,$container_name);
my $candidate=openat($root,$container_name,O_RDONLY|O_DIRECTORY,0); my @s=stat($candidate);
fail('"'"'Private recovery container creation identity changed'"'"') unless mount_id($candidate) eq $boundary->{mount_id} && file_handle($candidate) eq $created;
fail('"'"'Private recovery container ownership changed'"'"') unless $s[4]==0 && ($s[2]&0777)==0700 && !($s[2]&01000);
$container=$candidate;
# A setgid dataset parent can contribute its group bit; clear it only after
# authenticating the created inode, never on an unrelated substituted dir.
chmod(0700,$container)==1 or fail('"'"'Unable to secure proven private recovery container'"'"');
fail('"'"'Private recovery container is not empty; no metadata written'"'"') if entries($container);
# The public parent may be0777. Never unlink the public container name:
# Linux lacks inode-conditional unlinkat. Retain the journal on success too.
my $file=anchor($container).'"'"'/journal.jsonl'"'"';
sysopen($journal,$file,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or fail('"'"'Unable to create recovery journal'"'"');
record({event=>'"'"'created'"'"',container_identity=>identity($container)});
$container->sync && $root->sync or fail('"'"'Unable to flush private recovery container'"'"');
emit({phase=>'"'"'prepared'"'"',recovery=>recovery()});
}
sub capture {
my ($from,$name,$expected,$original)=@_; my $slot='"'"'object-'"'"'.(++$serial);
record({event=>'"'"'intent'"'"',slot=>$slot,path=>$original,identity=>$expected});
fail('"'"'Directory changed or cannot be claimed; no destination was overwritten'"'"') unless renamed($from,$name,$container,$slot);
($bulk||($from->sync && $container->sync)) or fail('"'"'Unable to flush claim; inspect recovery journal'"'"');
my $captured;
eval { $captured=openat($container,$slot,O_PATH_LINUX|O_NOFOLLOW,0); 1 } or do {
my $error=$@; my $restored=renamed($container,$slot,$from,$name);
$from->sync && $container->sync or fail('"'"'Unable to flush rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$original});
fail($restored?'"'"'Captured object is unavailable; original name restored'"'"':$error.'"'"'; object retained; original name occupied or moved'"'"');
};
if (!same(identity($captured),$expected)) {
my $restored=renamed($container,$slot,$from,$name);
$from->sync && $container->sync or fail('"'"'Unable to flush rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$original,identity=>identity($captured)});
fail($restored?'"'"'Directory identity changed; unexpected object restored'"'"':'"'"'Directory identity changed; unexpected object retained; original name occupied or moved'"'"');
}
my @captured_stat=stat($captured);
$captured=openat($captured,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0) if S_ISDIR($captured_stat[2]);
my $where=readlink(anchor($from));
my $observed=defined($where)?$where.'"'"'/'"'"'.$name:'"'"'unavailable'"'"';
$source_location={requested=>$original,location=>$observed} if $serial==1;
record({event=>'"'"'captured'"'"',slot=>$slot,path=>$original,source_location=>$observed,identity=>$expected});
return ($captured,$slot);
}
sub inspect_tree {
my ($dir,$relative,$manifest,$recursive)=@_;
for my $name (entries($dir)) {
fail('"'"'Reserved directory entry cannot be mutated'"'"') if $name eq '"'"'.zfs'"'"'||$name=~/^\.zfsm-/;
my $child=openat($dir,$name,O_PATH_LINUX|O_NOFOLLOW,0); my @s=stat($child);
$child=openat($child,'"'"'.'"'"',O_RDONLY|O_DIRECTORY,0) if S_ISDIR($s[2]);
# Non-directories (including links, FIFOs, sockets and device nodes)
# are pinned only with O_PATH|O_NOFOLLOW; never opened for I/O.
fail('"'"'Directory is not empty; recursive deletion is required'"'"') unless $recursive;
my $p=$relative eq '"'"''"'"'?$name:$relative.'"'"'/'"'"'.$name;
# Names preserve the preflight entry set. Child identities are pinned
# freshly immediately before their individual private claims.
$manifest->{$p}={directory=>S_ISDIR($s[2])?1:0} if $manifest;
inspect_tree($child,$p,$manifest,$recursive) if S_ISDIR($s[2]);
}
}
sub remove_tree {
my ($dir,$slot,$relative,$manifest)=@_;
for my $name (entries($dir)) {
my $p=$relative eq '"'"''"'"'?$name:$relative.'"'"'/'"'"'.$name; my $known=$manifest->{$p};
fail('"'"'Directory contents changed; remaining objects retained'"'"') unless $known;
# Selected-root authorization covers current contents. Pin each
# current child through capture/verification, preventing inode reuse
# without retaining one descriptor for every file in a large tree.
my $current=openat($dir,$name,O_PATH_LINUX|O_NOFOLLOW,0);
my @current_stat=stat($current);
fail('"'"'Directory entry type changed; remaining objects retained'"'"') if !!S_ISDIR($current_stat[2]) != !!$known->{directory};
my ($child,$captured)=capture($dir,$name,identity($current),$p);
close($current); # The verified captured handle now pins this inode.
if ($known->{directory}) { remove_tree($child,$captured,$p,$manifest); }
else {
my $raw=raw($captured);
fail('"'"'Unable to delete captured file; remaining objects retained'"'"') if syscall($nr{unlinkat},fileno($container),$raw,0)!=0;
record({event=>'"'"'deleted'"'"',slot=>$captured,path=>$p});
deletion_progress();
}
}
my $raw=raw($slot);
fail('"'"'Directory received new files or left its boundary; remaining objects retained'"'"') if syscall($nr{unlinkat},fileno($container),$raw,512)!=0;
record({event=>'"'"'deleted'"'"',slot=>$slot,path=>$relative});
deletion_progress();
}
sub request_root {
my ($args)=@_; my $root_path=$args->{root}; fail('"'"'Invalid dataset root'"'"') unless defined($root_path)&&!ref($root_path)&&$root_path=~m{^/[^/]}&&$root_path!~/\0/&&$root_path!~m{/$};
sysopen(my $slash,'"'"'/'"'"',O_RDONLY|O_DIRECTORY) or fail('"'"'Unable to pin filesystem root'"'"');
my $dir=openat($slash,substr($root_path,1),O_RDONLY|O_DIRECTORY,1);
fail('"'"'The filesystem root cannot be a dataset mutation root'"'"') if same(identity($dir),identity($slash));
fail('"'"'Dataset root identity changed'"'"') unless same(identity($dir),$args->{root_identity}); return $dir;
}
# Only journal-only completed mutation containers are eligible. Never recurse.
sub cleanup_inspect {
my ($dir)=@_; my @s=stat($dir);
fail('"'"'Recovery container must be root-owned and private0700'"'"') unless $s[4]==0 && ($s[2]&07777)==0700;
my @names=entries($dir); fail('"'"'Recovery data is retained or the journal is missing; cleanup blocked'"'"') unless @names==1 && $names[0] eq '"'"'journal.jsonl'"'"';
my $file=openat($dir,'"'"'journal.jsonl'"'"',O_RDONLY|O_NOFOLLOW|O_NONBLOCK,0);my @f=stat($file);
fail('"'"'Recovery journal must be a private root-owned regular file with one link'"'"') unless S_ISREG($f[2]) && $f[4]==0 && ($f[2]&07777)==0600 && $f[3]==1 && $f[7]>0 && $f[7]<=262144;
my $bytes='"'"''"'"';while(read($file,my $chunk,16384)){ $bytes.=$chunk;fail('"'"'Recovery journal exceeds read bound'"'"') if length($bytes)>262144; }
fail('"'"'Recovery journal changed while reading'"'"') unless length($bytes)==$f[7] && same(identity($file),identity(openat($dir,'"'"'journal.jsonl'"'"',O_PATH_LINUX|O_NOFOLLOW,0)));
fail('"'"'Incomplete recovery journal'"'"') unless $bytes=~/\n\z/;
my @lines=split /\n/,$bytes;fail('"'"'Recovery journal exceeds record limit'"'"') if @lines>4096;
my (%slots,$created,$complete,$published,$op);my $id=identity($dir);
for my $line (@lines){
my $row=eval{$json->decode($line)};fail('"'"'Malformed recovery journal'"'"') unless ref($row)eq'"'"'HASH'"'"' && !defined($complete);
my $event=$row->{event}//'"'"''"'"';
if(!$created){fail('"'"'Recovery journal identity does not match container'"'"') unless $event eq '"'"'created'"'"' && same($id,$row->{container_identity});$created=1;next;}
if($event eq'"'"'intent'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Malformed recovery intent'"'"') unless $slot=~/\Aobject-[1-9][0-9]*\z/ && !exists($slots{$slot}) && ref($row->{identity})eq'"'"'HASH'"'"' && defined($row->{path}) && !ref($row->{path});
$slots{$slot}={state=>'"'"'intent'"'"',identity=>$row->{identity}};
}elsif($event eq'"'"'captured'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Unmatched recovery capture'"'"') unless ($slots{$slot}{state}//'"'"''"'"')eq'"'"'intent'"'"' && same($slots{$slot}{identity},$row->{identity});$slots{$slot}{state}='"'"'captured'"'"';
}elsif($event eq'"'"'deleted'"'"'){
my $slot=$row->{slot}//'"'"''"'"';fail('"'"'Unmatched recovery deletion'"'"') unless ($slots{$slot}{state}//'"'"''"'"')eq'"'"'captured'"'"';$slots{$slot}{state}='"'"'deleted'"'"';
}elsif($event eq'"'"'published'"'"'){
fail('"'"'Unmatched recovery publication'"'"') unless !$published && keys(%slots)==1 && ($slots{'"'"'object-1'"'"'}{state}//'"'"''"'"')eq'"'"'captured'"'"' && same($slots{'"'"'object-1'"'"'}{identity},$row->{identity});$published=1;$slots{'"'"'object-1'"'"'}{state}='"'"'published'"'"';
}elsif($event eq'"'"'complete'"'"'){
$op=$row->{op}//'"'"''"'"';fail('"'"'Incomplete or inconsistent recovery operation'"'"') unless keys(%slots)>0 && (($op eq'"'"'delete'"'"' && !$published && !grep{$_->{state}ne'"'"'deleted'"'"'}values%slots) || (($op eq'"'"'move'"'"'||$op eq'"'"'claim'"'"') && $published && keys(%slots)==1));$complete=1;
}else{fail('"'"'Unfinished, retained or unsupported recovery journal; cleanup blocked'"'"');}
}
fail('"'"'Recovery operation is not complete'"'"') unless $complete;
@names=entries($dir);fail('"'"'Recovery contents changed'"'"') unless @names==1 && $names[0]eq'"'"'journal.jsonl'"'"';
return {identity=>$id,journal_identity=>identity($file),journal_sha256=>Digest::SHA::sha256_hex($bytes),journal_base64=>MIME::Base64::encode_base64($bytes,'"'"''"'"'),operation=>$op,records=>scalar(@lines)};
}
sub cleanup_anchor {
my ($policy)=@_;my $name='"'"'.zfsm-maintenance'"'"';my $p=raw($name);my $created;
if(!lstat(anchor($root).'"'"'/'"'"'.$name)){
fail('"'"'Unable to create cleanup maintenance area'"'"') if syscall($nr{mkdirat},fileno($root),$p,0700)!=0;
$created=creation_handle($policy,$name);
}
my $dir=openat($root,$name,O_RDONLY|O_DIRECTORY,0);my @s=stat($dir);
fail('"'"'Cleanup maintenance area creation identity changed'"'"') if defined($created) && file_handle($dir)ne$created;
fail('"'"'Cleanup maintenance area is not trusted'"'"') unless $s[4]==0 && mount_id($dir)eq$policy->{mount_id} && (defined($created)?(($s[2]&0777)==0700 && !($s[2]&05000)):($s[2]&07777)==0700);
# Clear inherited setgid only on the authenticated newly created inode.
chmod(0700,$dir)==1 or fail('"'"'Unable to secure cleanup maintenance area'"'"') if defined($created);
$dir->sync && $root->sync or fail('"'"'Unable to flush maintenance area'"'"');return $dir;
}
sub cleanup_main {
my ($args)=@_;my $selected=$args->{path}//'"'"''"'"';
fail('"'"'Only a top-level mutation recovery container can be cleaned'"'"') unless !ref($selected) && $selected=~/\A\.zfsm-mutation-[a-f0-9]{48}\z/;
$root_path=$args->{root};$root=request_root($args);full_root($root);
my $source=openat($root,$selected,O_RDONLY|O_DIRECTORY,0);fail('"'"'Recovery container identity changed'"'"') unless same(identity($source),$args->{identity});
my $review=cleanup_inspect($source);
if($args->{op}eq'"'"'cleanup-inspect'"'"'){emit({ok=>JSON::PP::true,op=>$args->{op},%$review});return;}
fail('"'"'Fresh recovery journal digest is required'"'"') unless defined($args->{journal_sha256}) && !ref($args->{journal_sha256}) && $args->{journal_sha256}=~/\A[a-f0-9]{64}\z/ && $args->{journal_sha256}eq$review->{journal_sha256};
my $policy=prepare_boundary($root);my $area=cleanup_anchor($policy);
sysopen(my $random,'"'"'/dev/urandom'"'"',O_RDONLY) or fail('"'"'Unable to create cleanup identity'"'"');read($random,my $bytes,24)==24 or fail('"'"'Unable to read cleanup identity'"'"');close($random);
my $slot='"'"'cleanup-'"'"'.unpack('"'"'H*'"'"',$bytes);$container=$area;$container_name='"'"'.zfsm-maintenance'"'"';
fail('"'"'Recovery container changed before capture'"'"') unless renamed($root,$selected,$area,$slot);
$root->sync && $area->sync or fail('"'"'Unable to flush recovery capture'"'"');
$source_location={requested=>$selected,location=>(readlink(anchor($area))//'"'"'unavailable'"'"').'"'"'/'"'"'.$slot};
my $captured=openat($area,$slot,O_RDONLY|O_DIRECTORY,0);
if(!same(identity($captured),$review->{identity})){
my $restored=renamed($area,$slot,$root,$selected);$area->sync;$root->sync;
fail($restored?'"'"'Recovery identity changed; substituted directory restored'"'"':'"'"'Recovery identity changed; substituted directory retained in maintenance area'"'"');
}
my $fresh=eval{cleanup_inspect($captured)};
if(!$fresh || $fresh->{journal_sha256}ne$review->{journal_sha256} || !same($fresh->{journal_identity},$review->{journal_identity})){
my $restored=renamed($area,$slot,$root,$selected);$area->sync;$root->sync;
fail($restored?'"'"'Recovery contents changed; container restored'"'"':'"'"'Recovery contents changed; container retained in maintenance area'"'"');
}
# The selected inode is now beneath a pinned root0700 parent. A writer of
# the public dataset root cannot replace the two names used for unlinkat.
my $journal_name=raw('"'"'journal.jsonl'"'"');fail('"'"'Unable to remove archived journal; inspect maintenance area'"'"') if syscall($nr{unlinkat},fileno($captured),$journal_name,0)!=0;
$captured->sync or fail('"'"'Unable to flush journal removal'"'"');my $name=raw($slot);
fail('"'"'Unable to remove empty recovery container; inspect maintenance area'"'"') if syscall($nr{unlinkat},fileno($area),$name,512)!=0;
$area->sync or fail('"'"'Unable to flush cleanup'"'"');$container=undef;
emit({ok=>JSON::PP::true,op=>'"'"'cleanup'"'"',removed_identity=>$review->{identity},journal_sha256=>$review->{journal_sha256},maintenance=>'"'"'.zfsm-maintenance'"'"'});
}
sub main {
my @u=uname(); my %abi=(x86_64=>[437,258,316,263,300,301,303,138],aarch64=>[437,34,276,35,262,263,264,44]);
fail('"'"'Safe directory mutations require Linux x86_64 or aarch64'"'"') unless $^O eq '"'"'linux'"'"' && $abi{$u[4]};
@nr{qw(openat2 mkdirat renameat2 unlinkat fanotify_init fanotify_mark name_to_handle_at fstatfs)}=@{$abi{$u[4]}};
fail('"'"'Safe directory mutations require the privileged local worker'"'"') unless $>==0;
fail('"'"'Unexpected command arguments'"'"') if @ARGV;
umask(0077); binmode(STDIN);binmode(STDOUT); $|=1;
my $raw='"'"''"'"'; while(read(STDIN,my $chunk,65536)) { $raw.=$chunk;fail('"'"'Request too large'"'"') if length($raw)>1048576; }
my $args=$json->decode($raw); fail('"'"'Invalid request'"'"') unless ref($args) eq '"'"'HASH'"'"';
my $op=$args->{op}//'"'"''"'"'; if ($op eq '"'"'probe'"'"') { probe($args);return; }
if($op eq '"'"'cleanup-inspect'"'"'||$op eq '"'"'cleanup'"'"'){cleanup_main($args);return;}
fail('"'"'Unknown directory operation'"'"') unless $op eq '"'"'move'"'"'||$op eq '"'"'claim'"'"'||$op eq '"'"'delete'"'"';
my $selected=path($args->{path}); my $destination;
$destination=path($args->{destination}) if $op ne '"'"'delete'"'"';
fail('"'"'Destination cannot be inside selected directory'"'"') if defined($destination)&&($destination eq $selected||index($destination,$selected.'"'"'/'"'"')==0);
$root_path=$args->{root}; $root=request_root($args); $boundary=prepare_boundary($root);
my ($from,$name)=parent($selected); my $source=openat($from,$name,O_RDONLY|O_DIRECTORY,0);
fail('"'"'Selected directory identity changed'"'"') unless same(identity($source),$args->{identity});
my ($to,$target); if ($op ne '"'"'delete'"'"') {
($to,$target)=parent($destination);
fail('"'"'Destination already exists'"'"') if lstat(anchor($to).'"'"'/'"'"'.raw($target));
}
emit({phase=>'"'"'scanning'"'"'}) if $op eq '"'"'delete'"'"';
my %manifest; inspect_tree($source,'"'"''"'"',$op eq '"'"'delete'"'"'?\%manifest:undef,$op eq '"'"'delete'"'"'?($args->{recursive}?1:0):1);
$delete_total=1+scalar(keys %manifest) if $op eq '"'"'delete'"'"';
container();
fail('"'"'Selected directory parent changed; reopen the directory'"'"') unless canonical_parent($selected,$from);
my ($claimed,$slot)=capture($from,$name,$args->{identity},$selected);
my $source_canonical=eval { canonical_parent($selected,$from) };
fail('"'"'Selected directory parent moved during claim; reviewed directory retained in recovery container'"'"') unless $source_canonical;
if ($op ne '"'"'delete'"'"') {
my $destination_canonical=eval { canonical_parent($destination,$to) };
if (!$destination_canonical) {
my $restored=renamed($container,$slot,$from,$name);
$container->sync && $from->sync or fail('"'"'Unable to flush move rollback; inspect recovery journal'"'"');
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$selected});
fail($restored?'"'"'Destination parent changed; original restored'"'"':'"'"'Destination parent changed; reviewed directory retained in recovery container'"'"');
}
if (!renamed($container,$slot,$to,$target)) {
my $restored=renamed($container,$slot,$from,$name);
record({event=>$restored?'"'"'rolled-back'"'"':'"'"'retained'"'"',slot=>$slot,path=>$selected});
fail($restored?'"'"'Move destination changed or left dataset; original restored'"'"':'"'"'Move refused; selected directory retained in recovery container'"'"');
}
$publication={requested=>$destination,handle=>$claimed};
$container->sync && $to->sync or fail('"'"'Unable to flush move; inspect destination and recovery journal'"'"');
my $actual=readlink(anchor($claimed));
record({event=>'"'"'published'"'"',destination=>$destination,location=>defined($actual)?$actual:'"'"'unavailable'"'"',identity=>identity($claimed)});
my $canonical=eval {
my $current=openat($root,$destination,O_PATH_LINUX|O_NOFOLLOW,0);
canonical_parent($destination,$to) && same(identity($current),identity($claimed));
};
fail('"'"'Destination moved during publication; reviewed directory retained at its reported actual location'"'"') unless $canonical;
} else {
# Each recursion root is already captured under the authenticated
# root0700 parent. Parent DAC prevents ordinary writers moving it out,
# including through old FDs. Uncaptured preflight handles are not used.
$bulk=1;
eval { remove_tree($claimed,$slot,'"'"''"'"',\%manifest); 1 } or do { my $error=$@; $bulk=0; my $flushed=$journal->sync && $container->sync; $error=~s/\s+$//; die $error.($flushed?'"'"''"'"':'"'"'; the recovery journal could not be flushed, inspect the recovery container'"'"')."\n"; };
$bulk=0;
$journal->sync && $container->sync or fail('"'"'Unable to flush deletion'"'"');
}
record({event=>'"'"'complete'"'"',op=>$op});
emit({ok=>JSON::PP::true,op=>$op,recovery=>recovery(),($op eq '"'"'delete'"'"'?(removed=>$delete_removed,total=>$delete_total):())});
}
unless (caller) {
$0='"'"'zfs-director folder helper'"'"';
eval { main();1 } or do {my $error=$@||'"'"'Directory mutation failed'"'"';$error=~s/\s+$//;print STDERR $json->encode({error=>$error,recovery=>recovery()})."\n";exit 1;};
}
1;
' <<< '{"op":"probe"}'; then
echo 'ZFS Director directory prerequisites unavailable; installed code and worker unchanged.' >&2
exit 1
fi
# A refused or failed update keeps the installed version; its services must not stay stopped.
runtime_stop=/var/tmp/zfs.master.ui/jobs/stop
stopped_by_installer=0
if [[ ! -e "$runtime_stop" && ! -L "$runtime_stop" ]]; then stopped_by_installer=1; fi
resume_runtime() {
[[ "$stopped_by_installer" == 1 ]] || return 0
flock -u 6 2>/dev/null || true; flock -u 7 2>/dev/null || true; flock -u 9 2>/dev/null || true
exec 6>&- 7>&- 9>&- || true
if [[ -f "$runtime_stop" && ! -L "$runtime_stop" ]]; then rm -f -- "$runtime_stop"; fi
if [[ -f "$live/scripts/zfsm-worker.php" ]]; then /usr/bin/php "$live/scripts/zfsm-worker.php" --launch >/dev/null 2>&1 || true; fi
echo 'ZFS Director: the installed version keeps running; its background services were restarted.'
}
early_failure() { local result=$?; if [[ "$result" != 0 ]]; then resume_runtime; fi; }
trap early_failure EXIT
stop_runtime 'package installation in progress'
if [[ -f "$live/scripts/zfsm-worker.php" ]]; then /usr/bin/php "$live/scripts/zfsm-worker.php" --stop; fi
exec 9>>/var/lock/zfs.master.ui.lock
flock -n 9 || { echo 'ZFS Director: an operation is active; wait for it to finish and retry installation.'; exit 1; }
mkdir -p /boot/config/plugins/zfs.master.ui/protection
exec 7>>/boot/config/plugins/zfs.master.ui/protection/config.lock
exec 6>>/boot/config/plugins/zfs.master.ui/protection/execution.lock
flock -n 7 && flock -n 6 || { echo 'ZFS Director: protection configuration or execution is busy; retry after it finishes.'; exit 1; }
# Recheck under the mutation lock: settings may have changed during preflight.
preflight
mkdir -m 0700 "$stage"
tar -xzf "$archive" -C "$stage"
(cd "$stage/zfs.master.ui"; sha256sum -c SHA256SUMS)
while IFS= read -r -d '' file; do /usr/bin/php -l "$file" >/dev/null; done < <(find "$stage/zfs.master.ui" -type f \( -name '*.php' -o -name '*.page' \) -print0)
/usr/bin/perl -c "$stage/zfs.master.ui/scripts/zfsm-files.pl"
find "$stage/zfs.master.ui" -type d -exec chmod 0755 {} +
rollback_install() {
local result=$?
if [[ "$result" != 0 ]]; then
if ! legacy_cleanup rollback; then echo "ZFS Director: automatic rollback needs inspection: $legacy_backup"; return; fi
if [[ -n "$old_receipt" ]]; then printf '%s\n' "$old_receipt" > "$installed"; else rm -f -- "$installed"; fi
if [[ -d "$live" ]]; then mv -T "$live" "$stage.failed"; fi
if [[ -d "$previous" ]]; then mv -T "$previous" "$live"; fi
echo 'ZFS Director: previous code and registrations restored; state and recovery retained.'
resume_runtime
fi
}
trap rollback_install EXIT
if [[ -d "$live" ]]; then mv -T "$live" "$previous"; fi
mv -T "$stage/zfs.master.ui" "$live"
rmdir "$stage"
legacy_cleanup apply
# Automatic installation receipt distinguishes boot from an unrelated upgrade.
if [[ "$hold_recovery" == 0 ]]; then
receipt=$(mktemp "$installed.XXXXXX")
printf '%s\n' 'ab5339f5398e20edf41cfb3769e98db0db25f56a2e28589a270e5ee140ad7c2d' > "$receipt"
mv -T -- "$receipt" "$installed"
fi
# Startup must follow Plugin Manager registration, not precede it.
hook=/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director
[[ ! -L "$hook" && ( ! -e "$hook" || -f "$hook" ) ]]
mkdir -p "$(dirname "$hook")"
cp "$live/scripts/zfsm-plugin-hook" "$hook"
chmod 0755 "$hook"
/usr/bin/php -r '
$path="/var/tmp/zfs.master.ui/jobs/install-pending.json";
if(is_link($path)||(file_exists($path)&&!is_file($path)))exit(1);
$json=json_encode(["registration"=>$argv[1],"sha256"=>$argv[2],"hold_recovery"=>$argv[3]==="1"],JSON_THROW_ON_ERROR);
$temporary=tempnam(dirname($path),".install-");
if($temporary===false||file_put_contents($temporary,$json)!==strlen($json)||!rename($temporary,$path))exit(1);
' -- "$registration" 'ab5339f5398e20edf41cfb3769e98db0db25f56a2e28589a270e5ee140ad7c2d' "$hold_recovery"
# No child service inherits the installer's operation/protection locks.
flock -u 6
flock -u 7
exec 6>&- 7>&-
flock -u 9
exec 9>&-
trap - EXIT
echo 'ZFS Director 2026.11.13.3 code installed; Plugin Manager will verify registration and start background services next.'
set -euo pipefail
exec 2>&1
umask 077
live=/usr/local/emhttp/plugins/zfs.master.ui
installed=/boot/config/plugins/zfs.master.ui/installed.sha256
if [[ -L "$live" || ( -e "$live" && ! -d "$live" ) || -L "$(dirname "$installed")" || -L "$installed" || ( -e "$installed" && ! -f "$installed" ) || -L /var/lock/zfs.master.ui.lock || ( -e /var/lock/zfs.master.ui.lock && ! -f /var/lock/zfs.master.ui.lock ) ]]; then
echo 'ZFS Director: unsafe uninstall path; no code or services changed.' >&2
exit 1
fi
for service_lock in /var/lock/zfs.master.ui.service.lock /var/lock/zfs.master.ui.worker.lock /var/tmp/zfs.master.ui/jobs/monitor.lock; do
if [[ -L "$service_lock" || ( -e "$service_lock" && ! -f "$service_lock" ) ]]; then
echo 'ZFS Director: untrusted service lock; no code or services changed.' >&2
exit 1
fi
done
/usr/bin/perl -e 'use strict;
use warnings;
use Fcntl qw(:DEFAULT :mode);
my ($base, $owner, $message) = @ARGV;
die "Invalid runtime guard arguments\n" unless defined($message) && $owner =~ /\A[0-9]+\z/ && $base =~ m{\A/} && $base !~ m{(?:\A|/)\.\.?(/|\z)};
sub enter_directory {
my ($name, $uid, $shared) = @_;
sysopen(my $directory, $name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) or die "Runtime directory requires inspection\n";
my @stat = stat($directory);
die "Untrusted runtime directory\n" unless @stat && S_ISDIR($stat[2]) && $stat[4] == $uid
&& (!(($stat[2]) & 0022) || ($shared && ($stat[2] & 01000)));
chdir($directory) or die "Unable to enter trusted runtime directory\n";
}
enter_directory('"'"'/'"'"', 0, 0);
my @parents = grep { length } split m{/}, $base;
for my $index (0 .. $#parents) {
enter_directory($parents[$index], $index == $#parents ? $owner : 0, 1);
}
for my $name ('"'"'zfs.master.ui'"'"', '"'"'jobs'"'"') {
if (!lstat($name)) { mkdir($name, 0700) or die "Unable to create runtime directory\n"; }
enter_directory($name, $owner, 0);
}
my @stop = lstat('"'"'stop'"'"');
die "Stop marker requires inspection\n" if @stop && (!S_ISREG($stop[2]) || $stop[4] != $owner || ($stop[2] & 0022) || $stop[3] != 1);
my $temporary = '"'"'.stop-'"'"' . $$ . '"'"'-'"'"' . int(rand(1000000000));
sysopen(my $marker, $temporary, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600) or die "Unable to create stop marker\n";
eval {
print {$marker} $message, "\n" or die "Unable to write stop marker\n";
close($marker) or die "Unable to close stop marker\n";
rename($temporary, '"'"'stop'"'"') or die "Unable to publish stop marker\n";
1;
} or do {
my $error = $@;
close($marker) if defined(fileno($marker));
unlink($temporary);
die $error;
};
' -- /var/tmp 0 'plugin removal in progress'
if [[ -f "$live/scripts/zfsm-worker.php" ]]; then
if ! /usr/bin/php "$live/scripts/zfsm-worker.php" --stop; then
echo 'ZFS Director: background work has not stopped. Wait for the active operation, inspect Jobs, then retry removal. Code and recovery data retained.' >&2
exit 1
fi
fi
exec 9>>/var/lock/zfs.master.ui.lock
if ! flock -n 9; then
echo 'ZFS Director: an operation is active. Retry removal when it finishes; no lock was deleted.' >&2
exit 1
fi
# Also cover an incomplete installation with no usable worker stop script.
for service_lock in /var/lock/zfs.master.ui.service.lock /var/lock/zfs.master.ui.worker.lock /var/tmp/zfs.master.ui/jobs/monitor.lock; do
if [[ -L "$service_lock" || ( -e "$service_lock" && ! -f "$service_lock" ) ]]; then
echo 'ZFS Director: untrusted service lock; code retained for inspection.' >&2
exit 1
fi
if [[ -f "$service_lock" ]]; then
exec 8>>"$service_lock"
if ! flock -n 8; then
echo 'ZFS Director: a background service is still running; retry removal after it stops. No process was killed.' >&2
exit 1
fi
exec 8>&-
fi
done
hook=/usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks/zfs-director
if [[ ! -L /usr/local/emhttp/plugins/dynamix.plugin.manager && ! -L /usr/local/emhttp/plugins/dynamix.plugin.manager/post-hooks && -f "$live/scripts/zfsm-plugin-hook" && -f "$hook" && ! -L "$hook" ]] && cmp -s "$live/scripts/zfsm-plugin-hook" "$hook"; then rm -- "$hook"; fi
# Reinstallation is a new install, never an accepted-boot reconstruction.
# Do not remove settings, journals, archives, recovery or external schedules.
rm -f -- "$installed"
rm -f -- /boot/config/plugins/zfs.master.ui/zfs-director.plg /boot/config/plugins/zfs.master.ui/zfs.master.ui.plg /boot/config/plugins/zfs.master.ui/zfs.master.ui.local.plg
rm -rf -- "$live"
echo 'ZFS Director removed. Settings, Jobs and recovery data retained; external Sanoid/Syncoid configuration unchanged.'