Fix to make packaged unit edits opt-in

ⓘ Default packaged unit files to read-only, keep drop-ins as the safe override path, hide boot controls for protected base units, and reject [Install] sections in drop-in overrides.
This commit is contained in:
Ilia Ross
2026-06-23 23:24:38 +02:00
parent 41b476c87a
commit 0290ec16a5
9 changed files with 235 additions and 16 deletions

View File

@@ -5,6 +5,7 @@ visible_tabs=service,timer,socket,path,target,storage,resources,device,user
show_runtime_units=1
default_create_scope=system
manual_vendor_units=1
edit_vendor_units=0
delete_vendor_units=0
default_linger=1
show_unit_suffixes=0

View File

@@ -5,6 +5,7 @@ visible_tabs=Tabs to show on the index page,15,visible_tabs
show_runtime_units=Show generated and transient units,1,1-Yes,0-No
default_create_scope=Default scope for new units,1,system-System units,user-User units
manual_vendor_units=Include vendor unit files in the manual editor,1,1-Yes,0-No
edit_vendor_units=Allow editing packaged unit files,1,1-Yes,0-No
delete_vendor_units=Allow deleting packaged unit files,1,1-Yes,0-No
default_linger=Enable linger by default for new user units,1,1-Yes,0-No
show_unit_suffixes=Show full unit names with type suffixes,1,1-Yes,0-No

View File

@@ -23,6 +23,7 @@ my $info = $allowed{$in{'file'}} || $files[0];
my $file = $info->{'file'};
my $data = read_manual_unit_file($info);
defined($data) || error($text{'manual_eread'});
my $file_writable = manual_unit_file_writable($info);
ui_print_header(undef, $text{'manual_title'}, "");
my $desc = $info->{'scope'} eq 'user' ?
@@ -43,9 +44,11 @@ print ui_form_end();
print ui_form_start("save_manual.cgi", "form-data");
print ui_hidden("file", $file);
print ui_table_start(undef, undef, 2);
print ui_table_row(undef, ui_textarea("data", $data, 35, 120), 2);
print ui_table_row(undef, ui_textarea("data", $data, 35, 120, undef,
undef, $file_writable ? undef :
"readonly='readonly'"), 2);
print ui_table_end();
print ui_form_end([ [ "save", $text{'save'} ] ]);
print ui_form_end($file_writable ? [ [ "save", $text{'save'} ] ] : undef);
ui_print_footer("index.cgi", $text{'index_return'});

View File

@@ -143,7 +143,9 @@ my (@units, @unittypes, @types, @killmodes, @restarts, @protects);
my (%creatable_types);
my $default_unittype = 'service';
my $unit_file_editable = 0;
my $unit_file_writable = 0;
my $can_save_unit = 0;
my $can_write_current_file = 0;
my $remote_uinfo = get_user_details($remote_user);
# New units start with an empty record. Existing units are looked up from the
@@ -186,12 +188,17 @@ else {
$dropin_file = $dropin_info->{'file'};
}
$unit_file_editable = unit_file_editable($u);
$unit_file_writable = $edit_user_scope ? $unit_file_editable :
system_unit_file_writable($u);
$can_save_unit = $edit_dropin ?
systemd_can_dropin($edit_user_scope, $unituser) :
systemd_can_edit($edit_user_scope, $unituser);
$can_write_current_file =
($edit_dropin ? $unit_file_editable : $unit_file_writable) &&
$can_save_unit ? 1 : 0;
# Runtime-managed units are inspect-only, so title them as views.
my $title_key = $unit_file_editable && $can_save_unit ?
my $title_key = $can_write_current_file ?
($edit_user_scope ? 'systemd_title2_user' : 'systemd_title2') :
($edit_user_scope ? 'systemd_title2_view_user' :
'systemd_title2_view');
@@ -985,8 +992,8 @@ else {
}
print ui_table_row(hlink($text{'systemd_conf'}, "systemd_conf"),
ui_textarea("data", $conf, 20, 80, undef,
undef, $unit_file_editable &&
$can_save_unit ? undef :
undef, $can_write_current_file ?
undef :
"readonly='readonly'"));
if ($edit_user_scope) {
@@ -1010,6 +1017,7 @@ else {
# Only file-backed installable units can have their startup state changed.
if (boot_state_changeable($u->{'unitstate'}, $u->{'name'}) &&
($edit_user_scope || $unit_file_writable) &&
systemd_can_boot($edit_user_scope, $unituser)) {
print ui_table_row(hlink($text{'systemd_boot'}, "systemd_boot"),
ui_yesno_radio("boot", $u->{'boot'}));
@@ -1036,7 +1044,7 @@ if ($in{'new'}) {
else {
# Keep save, override, runtime and inspection actions in nearby clusters;
# destructive actions stay isolated on the far side of the button row.
my @save_buttons = $unit_file_editable && $can_save_unit ?
my @save_buttons = $can_write_current_file ?
( [ undef, $text{'save'} ] ) : ( );
my @control_buttons;
my @inspect_buttons = systemd_can_inspect($edit_user_scope, $unituser) ?
@@ -1066,18 +1074,21 @@ else {
my @override_buttons;
if ($edit_dropin) {
my $base_unit_editable =
$unit_file_writable &&
systemd_can_edit($edit_user_scope, $unituser);
my $stock_text = $base_unit_editable
? $text{'edit_stockunitnow'}
: $text{'edit_view_stockunitnow'};
push(@override_buttons,
[ 'stock_unit',
$text{'edit_stockunitnow'} || "Stock Unit" ]);
[ 'stock_unit', $stock_text ]);
}
elsif ($unit_file_editable &&
systemd_can_dropin($edit_user_scope, $unituser)) {
my $override_text = dropin_exists($edit_user_scope,
$unituser, $in{'name'}) ?
($text{'edit_editoverridenow'} ||
"Edit Override") :
($text{'edit_overridenow'} ||
"Create Override");
$text{'edit_editoverridenow'} :
$text{'edit_overridenow'};
push(@override_buttons, [ 'override', $override_text ]);
}
my @delete_buttons;
@@ -1085,7 +1096,7 @@ else {
systemd_can_dropin($edit_user_scope, $unituser)) {
push(@delete_buttons,
[ 'delete_override',
$text{'edit_deleteoverridenow'} || "Delete Override" ]);
$text{'edit_deleteoverridenow'} ]);
}
elsif (($edit_user_scope || system_unit_file_deletable($u)) &&
$unit_file_editable && $in{'name'} ne 'webmin.service' &&

View File

@@ -0,0 +1,10 @@
<header>Allow editing packaged unit files</header>
<p>Controls whether the module may directly save changes to package-managed
unit files from vendor directories such as <tt>/usr/lib/systemd/system</tt>
or <tt>/lib/systemd/system</tt>. This is disabled by default because package
updates may overwrite or depend on those files.</p>
<p>When set to no, packaged unit files can still be inspected in the manual
editor when vendor files are included, but they are read-only. Use drop-in
overrides or local units under <tt>/etc/systemd/system</tt> for normal
customization.</p>

View File

@@ -51,6 +51,7 @@ edit_logsnow=Logs
edit_overridenow=Create Override
edit_editoverridenow=Edit Override
edit_stockunitnow=Edit Base Unit
edit_view_stockunitnow=View Base Unit
edit_deleteoverridenow=Delete Override
ss_ecannot=You are not allowed to start or stop systemd units
acl_section_users=User unit owner restrictions
@@ -217,6 +218,7 @@ systemd_title2_user=Edit Systemd User Unit
systemd_title2_view=View Systemd Unit
systemd_title2_view_user=View Systemd User Unit
systemd_egone=Unit no longer exists!
systemd_evendoredit=Editing packaged unit files is disabled in the module configuration. Create a drop-in override, or enable packaged unit editing first.
systemd_elocaldelete=Deleting packaged unit files is disabled in the module configuration. Disable, mask or create a drop-in override, or enable packaged unit deletion first.
systemd_header=Systemd unit details
systemd_name=Unit name
@@ -314,6 +316,7 @@ systemd_euserhome=Failed to create systemd user unit directory
systemd_euserunitfile=Unsafe or invalid systemd user unit file
systemd_euserunitdir=Unsafe or wrongly-owned systemd user unit directory
systemd_edropinfile=Unsafe or invalid systemd drop-in override file
systemd_edropininstall=Drop-in overrides cannot contain an [Install] section. Use enable or disable actions to change startup state.
systemd_ereadonly=This systemd unit file is managed at runtime and cannot be edited directly.
systemd_eloginctl=The loginctl command is not available on your system
systemd_esystemctl=The systemctl command is not available on your system

View File

@@ -697,6 +697,9 @@ else {
if (!unit_file_editable($u)) {
error($text{'systemd_ereadonly'});
}
if (!$edit_dropin && !$user_scope && !system_unit_file_writable($u)) {
error($text{'systemd_evendoredit'});
}
$in{'data'} =~ /\S/ || error($text{'systemd_econf'});
$in{'data'} =~ s/\r//g;
my $save_data = $edit_dropin ?
@@ -788,7 +791,8 @@ else {
# Apply startup state changes after saving the config.
if (defined($in{'boot'}) &&
boot_state_changeable($u->{'unitstate'}, $u->{'name'})) {
boot_state_changeable($u->{'unitstate'}, $u->{'name'}) &&
($user_scope || system_unit_file_writable($u))) {
systemd_can_boot($user_scope, $unituser) ||
systemd_acl_error('pboot');
if ($user_scope) {

View File

@@ -32,6 +32,9 @@ $config{"default_create_scope"} = "system"
$config{"default_create_scope"} !~ /^(system|user)$/);
$config{"manual_vendor_units"} = 1
if (!defined($config{"manual_vendor_units"}));
$config{"edit_vendor_units"} = 0
if (!defined($config{"edit_vendor_units"}) ||
$config{"edit_vendor_units"} !~ /^[01]$/);
$config{"delete_vendor_units"} = 0
if (!defined($config{"delete_vendor_units"}) ||
$config{"delete_vendor_units"} !~ /^[01]$/);
@@ -1756,10 +1759,12 @@ systemd-analyze cannot reliably load their temporary copies by name.
sub verify_dropin_data
{
my ($file, $unit_data, $dropin_data, $user_scope, $unitstate, $user) = @_;
my $analyze = has_command("systemd-analyze");
return (1, undef) if (!$analyze);
return (0, $text{'systemd_econf'})
if (!defined($unit_data) || !defined($dropin_data));
return (0, $text{'systemd_edropininstall'})
if (dropin_has_install_section($dropin_data));
my $analyze = has_command("systemd-analyze");
return (1, undef) if (!$analyze);
my $name = $file;
$name =~ s/^.*\///;
return (0, $text{'systemd_ename'}) if (!valid_unit_file_name($name));
@@ -1905,6 +1910,8 @@ Writes the standard local drop-in override file for a system unit.
sub write_system_dropin_file
{
my ($unit, $data) = @_;
return (0, $text{'systemd_edropininstall'})
if (dropin_has_install_section($data));
my $file = system_dropin_file($unit);
return (0, $text{'systemd_ename'}) if (!$file);
my $dir = $file;
@@ -2009,6 +2016,8 @@ Writes the standard user drop-in override file as the owning Unix user.
sub write_user_dropin_file
{
my ($user, $unit, $data) = @_;
return (0, $text{'systemd_edropininstall'})
if (dropin_has_install_section($data));
my $file = user_dropin_file($user, $unit);
return (0, $text{'systemd_euserunitfile'})
if (!$file || !user_dropin_file_safe($user, $file, 0));
@@ -2174,6 +2183,8 @@ Writes a safe existing system drop-in config file.
sub write_system_dropin_config_file
{
my ($file, $data) = @_;
return (0, $text{'systemd_edropininstall'})
if (dropin_has_install_section($data));
return (0, $text{'systemd_edropinfile'})
if (!system_dropin_config_file_safe($file, 1));
return (1, undef) if (is_readonly_mode());
@@ -2252,6 +2263,8 @@ Writes a safe existing user drop-in config file as the owning Unix user.
sub write_user_dropin_config_file
{
my ($user, $file, $data) = @_;
return (0, $text{'systemd_edropininstall'})
if (dropin_has_install_section($data));
return (0, $text{'systemd_edropinfile'})
if (!user_dropin_config_file_safe($user, $file, 1));
return (1, undef) if (is_readonly_mode());
@@ -2418,6 +2431,24 @@ $data =~ s/^### Lines below this comment will be discarded\s*\n.*\z//ms;
return $data;
}
=head2 dropin_has_install_section(data)
Returns 1 if a drop-in contains an active C<[Install]> section. Drop-ins are
for unit overrides; startup state is managed by enable and disable actions.
=cut
sub dropin_has_install_section
{
my ($data) = @_;
return 0 if (!defined($data));
foreach my $line (split(/\n/, $data)) {
$line =~ s/\r//g;
next if ($line =~ /^\s*(?:[#;]|$)/);
return 1 if ($line =~ /^\s*\[\s*Install\s*\]\s*(?:[#;].*)?$/i);
}
return 0;
}
=head2 delete_user_unit_file(user, file)
Deletes a user unit file as the owning Unix user after path validation, so a
@@ -3022,6 +3053,8 @@ sub write_manual_unit_file
my ($info, $data) = @_;
return (0, $text{'manual_efile'})
if (!$info || !$info->{'file'});
return (0, $text{'systemd_evendoredit'})
if (!manual_unit_file_writable($info));
$data = "" if (!defined($data));
$data =~ s/\0//g;
$data =~ s/\r//g;
@@ -3067,6 +3100,20 @@ unlock_file($info->{'file'});
return (1, undef);
}
=head2 manual_unit_file_writable(info)
Returns 1 if a manual editor file descriptor may be saved.
=cut
sub manual_unit_file_writable
{
my ($info) = @_;
return 0 if (!$info || !$info->{'scope'});
return 1 if ($info->{'scope'} eq 'user');
return 1 if ($info->{'kind'} && $info->{'kind'} eq 'dropin');
return system_unit_file_edit_allowed($info->{'file'}, $info->{'name'});
}
=head2 mark_units_changed()
Updates the flag file indicating that manual unit-file edits need reload.
@@ -3579,6 +3626,51 @@ return 0 if (!unit_file_editable($unit));
return system_unit_file_delete_allowed($unit->{'file'}, $unit->{'name'});
}
=head2 system_unit_file_writable(&unit)
Returns 1 if a system unit record points to a unit file that can be edited
under the current module configuration.
=cut
sub system_unit_file_writable
{
my ($unit) = @_;
return 0 if (!unit_file_editable($unit));
return system_unit_file_edit_allowed($unit->{'file'}, $unit->{'name'});
}
=head2 system_unit_file_edit_allowed(file, name)
Returns 1 if a system unit path is in a root where editing unit files is
currently permitted.
=cut
sub system_unit_file_edit_allowed
{
my ($file, $name) = @_;
return 0 if (!$file || !$name || !valid_unit_name($name));
foreach my $root (get_system_unit_file_root_candidates()) {
next if (!system_unit_root_edit_allowed($root));
if ($file eq $root."/".$name) {
return 0 if (-l $file);
return 1;
}
}
return 0;
}
=head2 system_unit_root_edit_allowed(root)
Returns 1 if system unit files under this root may be edited.
=cut
sub system_unit_root_edit_allowed
{
my ($root) = @_;
return (local_unit_file_root($root) ||
$config{'edit_vendor_units'} eq '1') ? 1 : 0;
}
=head2 system_unit_file_delete_allowed(file, name)
Returns 1 if a system unit path is in a root where deleting unit files is

View File

@@ -77,9 +77,11 @@ our (%access, %config, %in, %text, %gconfig, $remote_user);
systemd_euserunitfile => 'bad user unit file',
systemd_euserunitdir => 'bad user unit dir',
systemd_edropinfile => 'bad drop-in file',
systemd_edropininstall => 'bad drop-in install section',
systemd_ereadonly => 'runtime unit file',
systemd_ename => 'bad unit name',
systemd_egone => 'unit gone',
systemd_evendoredit => 'vendor unit edit disabled',
systemd_elocaldelete => 'local unit delete only',
systemd_eclash => 'unit clash',
systemd_emountwhat => 'missing mount source',
@@ -197,6 +199,27 @@ ok(!unit_file_editable({
unitstate => 'generated',
}),
'generated unit files are read-only');
ok(system_unit_file_writable({
name => 'local.service',
file => '/etc/systemd/system/local.service',
unitstate => 'enabled',
}),
'local system unit files can be edited directly');
ok(!system_unit_file_writable({
name => 'vendor.service',
file => '/usr/lib/systemd/system/vendor.service',
unitstate => 'enabled',
}),
'packaged system unit files cannot be edited directly by default');
{
local $config{'edit_vendor_units'} = 1;
ok(system_unit_file_writable({
name => 'vendor.service',
file => '/usr/lib/systemd/system/vendor.service',
unitstate => 'enabled',
}),
'packaged system unit files can be edited directly when configured');
}
ok(system_unit_file_deletable({
name => 'local.service',
file => '/etc/systemd/system/local.service',
@@ -1009,11 +1032,18 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
"[Service]\nRestart=always\n");
write_test_file("$packaged_root/vendor.socket", "");
write_test_file("$packaged_root/vendor.mount", "");
symlink("$packaged_root/vendor.socket", "$local_root/vendor-link.service");
my @commands;
local @main::list_units_cache = ();
local *main::get_system_unit_file_roots = sub {
return ($local_root, $packaged_root);
};
local *main::get_system_unit_file_root_candidates = sub {
return ($local_root, $packaged_root);
};
local *main::get_local_unit_root = sub {
return $local_root;
};
local *main::get_system_dropin_roots = sub {
return ($local_root);
};
@@ -1148,6 +1178,21 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
'manual unit files include system drop-in override files');
is($manual{"$local_root/demo.service.d/00-local.conf"}->{'kind'},
'dropin', 'manual drop-in descriptor is marked');
ok(manual_unit_file_writable($manual{"$local_root/local.path"}),
'manual local system unit files are writable');
ok(!manual_unit_file_writable($manual{"$packaged_root/vendor.mount"}),
'manual packaged unit files are read-only by default');
ok(!system_unit_file_writable({
name => 'vendor-link.service',
file => "$local_root/vendor-link.service",
unitstate => 'enabled',
}),
'local symlink unit files are not edited directly');
{
local $config{'edit_vendor_units'} = 1;
ok(manual_unit_file_writable($manual{"$packaged_root/vendor.mount"}),
'manual packaged unit files are writable when configured');
}
ok(!manual_system_unit_file_safe("$local_root/../escape.service"),
'manual system unit file safety rejects traversal');
my $manual_info = manual_unit_file("$local_root/local.path");
@@ -1161,6 +1206,8 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
manual_unit_file("$local_root/demo.service.d/00-local.conf");
ok($manual_dropin_info,
'manual_unit_file returns allowed drop-in descriptor');
ok(manual_unit_file_writable($manual_dropin_info),
'manual system drop-in files remain writable');
is(read_manual_unit_file($manual_dropin_info),
"[Service]\nRestart=always\n",
'read_manual_unit_file reads system drop-in files');
@@ -1170,6 +1217,12 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
is(slurp_test_file("$local_root/demo.service.d/00-local.conf"),
"[Service]\nRestart=on-failure\n",
'manual system drop-in write preserves exact file');
($ok, $err) = write_manual_unit_file(
$manual{"$packaged_root/vendor.mount"},
"[Mount]\nWhat=/tmp\nWhere=/vendor\n");
ok(!$ok, 'write_manual_unit_file rejects packaged unit writes by default');
is($err, $text{'systemd_evendoredit'},
'write_manual_unit_file reports packaged unit edit policy');
unlink($main::unit_config_change_flag);
unlink($main::daemon_reload_time_flag);
ok(!needs_daemon_reload(), 'daemon reload is not needed initially');
@@ -1280,6 +1333,13 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
'verify_dropin_data verifies the base unit name');
ok(!-e "$verify_root/verify-4/drop.service.d/override.conf",
'verify_dropin_data removes temporary override files');
($ok, $err) = verify_dropin_data(
'/etc/systemd/system/drop.service',
"[Unit]\nDescription=Drop\n[Service]\nExecStart=/bin/true\n",
"[Install]\nWantedBy=multi-user.target\n", 0);
ok(!$ok, 'verify_dropin_data rejects Install sections');
is($err, $text{'systemd_edropininstall'},
'verify_dropin_data reports Install section policy');
my $before_transient_verify = scalar(@verify_commands);
($ok, $err) = verify_dropin_data(
@@ -1346,6 +1406,10 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
"### Anything between here and the comment below will become ".
"the new contents of the file\n\n\n\n",
'dropin_effective_data discards commented base unit contents');
ok(dropin_has_install_section("[Install]\nWantedBy=multi-user.target\n"),
'drop-in install-section detector rejects active Install sections');
ok(!dropin_has_install_section("# [Install]\n[Service]\nRestart=always\n"),
'drop-in install-section detector ignores commented examples');
my ($ok, $out) = write_system_dropin_file('demo.service', $template);
ok($ok, 'write_system_dropin_file writes standard override files');
@@ -1368,6 +1432,12 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
"$dropin_root/demo.service.d/10-extra.conf",
"[Service]\nRestartSec=10s\n");
ok($ok, 'system drop-in config writer updates exact safe file');
($ok, $out) = write_system_dropin_config_file(
"$dropin_root/demo.service.d/10-extra.conf",
"[Install]\nWantedBy=multi-user.target\n");
ok(!$ok, 'system drop-in config writer rejects Install sections');
is($out, $text{'systemd_edropininstall'},
'system drop-in config writer reports Install section policy');
is(slurp_test_file("$dropin_root/demo.service.d/10-extra.conf"),
"[Service]\nRestartSec=10s\n",
'system drop-in config writer preserves non-standard filename');
@@ -1459,6 +1529,12 @@ like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$},
is(slurp_test_file("$root/demo.service.d/20-local.conf"),
"[Service]\nEnvironment=DEMO=2\n",
'user drop-in config writer preserves non-standard filename');
($ok, $out) = write_user_dropin_config_file(
'alice', "$root/demo.service.d/20-local.conf",
"[Install]\nWantedBy=default.target\n");
ok(!$ok, 'user drop-in config writer rejects Install sections');
is($out, $text{'systemd_edropininstall'},
'user drop-in config writer reports Install section policy');
ok(dropin_exists(1, 'alice', 'demo.service'),
'dropin_exists detects user override files');
is(read_user_dropin_file('alice', 'demo.service'),
@@ -2044,6 +2120,8 @@ like($config_source, qr/^default_create_scope=/m,
'module config exposes default create scope');
like($config_source, qr/^manual_vendor_units=/m,
'module config exposes vendor-file manual editor visibility');
like($config_source, qr/^edit_vendor_units=/m,
'module config exposes packaged unit edit policy');
like($config_source, qr/^delete_vendor_units=/m,
'module config exposes packaged unit delete policy');
like($config_source, qr/^default_linger=/m,
@@ -2185,6 +2263,12 @@ like($save_source, qr/write_user_unit_file/,
'save page uses safe user-unit writer');
like($save_source, qr/unit_file_editable/,
'save page rejects direct writes to runtime-managed unit files');
like($save_source, qr/system_unit_file_writable/,
'save page rejects direct writes to packaged unit files by default');
like($save_source, qr/systemd_evendoredit/,
'save page reports packaged unit edit policy');
like($save_source, qr/boot_state_changeable.*?\(\$user_scope \|\| system_unit_file_writable\(\$u\)\)/s,
'save page ignores edit-form boot changes for protected packaged units');
like($save_source, qr/verify_unit_data/,
'save page verifies raw unit edits before writing');
like($save_source, qr/dropin_template/,
@@ -2279,10 +2363,14 @@ like($edit_source,
like($edit_source,
qr/systemd_unituser.*systemd_runtime_state.*systemd_unit_state.*systemd_boot.*systemd_linger_user/s,
'edit page orders user unit metadata rows');
like($edit_source, qr/boot_state_changeable.*?\(\$edit_user_scope \|\| \$unit_file_writable\).*?systemd_can_boot/s,
'edit page hides boot radio for protected packaged units');
like($edit_source, qr/readonly='readonly'/,
'edit page shows runtime-managed unit files as read-only');
like($edit_source, qr/unit_file_editable/,
'edit page hides save and delete for runtime-managed unit files');
like($edit_source, qr/system_unit_file_writable/,
'edit page makes packaged system unit files read-only by default');
like($edit_source, qr/system_unit_file_deletable/,
'edit page hides delete for packaged system unit files');
like($edit_source, qr/edit_depsnow/,
@@ -2297,6 +2385,8 @@ like($edit_source, qr/edit_deleteoverridenow/,
'edit page labels override deletes clearly');
like($edit_source, qr/edit_stockunitnow/,
'edit page links override edits back to the stock unit');
like($edit_source, qr/edit_view_stockunitnow/,
'edit page labels protected base units as view-only from drop-ins');
like($edit_source, qr/stock_unit/,
'edit page uses a grouped button for stock-unit navigation');
like($edit_source, qr/systemd_can_edit/,
@@ -2353,6 +2443,8 @@ unlike($edit_manual_source, qr/action_links\(/,
'manual editor header omits daemon reload action');
like($edit_manual_source, qr/systemd_can_manual/,
'manual editor filters files by ACL');
like($edit_manual_source, qr/manual_unit_file_writable/,
'manual editor hides save for read-only packaged unit files');
like($dropins_source, qr/list_system_dropin_override_files/,
'drop-in inventory lists system drop-ins');
like($dropins_source, qr/list_all_user_dropin_override_files/,
@@ -2365,6 +2457,8 @@ like($dropins_source, qr/sub dropin_file_arg\b/,
'drop-in inventory links non-standard drop-ins by exact file');
like($save_manual_source, qr/write_manual_unit_file/,
'manual save uses constrained unit file writer');
like($lib_source, qr/sub manual_unit_file_writable\b/,
'library distinguishes writable manual files from read-only inventory');
like($save_manual_source, qr/mark_units_changed/,
'manual system save marks daemon reload as needed');
like($save_manual_source, qr/mark_user_units_changed/,