deprovision a member's linux account when the platform account goes

Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.

The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.

Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).

  reap    terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
          it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
          final count that must be zero or the account is not released.
  chown   fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
          it. The range is still captured first, because userdel removes the /etc/subuid entry and after
          that nothing on the machine remembers what it was. It is returned on every path including the
          failures, and logged as the exact assert-uid-free.sh command line.

Two guards the spec did not ask for, both pure and unit-tested:

  guardDeletable    ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
                    the platform would have confined, and uid >= 1000. Without it `userdel root` is one
                    bad users.osUser away and nothing else in the sequence would object.
  guardMemberTree   the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
                    database row and the result is the argument to a recursive chown.

chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.

destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.

deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.

NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 04:19:50 +00:00
co-authored by Claude Opus 5
parent f34d7fef70
commit 46799dada8
5 changed files with 564 additions and 12 deletions
+43
View File
@@ -3,6 +3,7 @@ import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_I
import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin.
@@ -104,6 +105,48 @@ export const deleteUserHandler: Handler = async function (ctx) {
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
// ── The Linux side goes FIRST, and its failure stops the delete ──
//
// This used to be the whole handler: remove the row, cascade the database, done. Measured on the
// production host on 2026-08-12, immediately after deleting a member through this route: their Linux
// account was still alive with a working login shell, their rootless Docker daemon was still running a
// healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
// they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
// the next member along with everything still owned by it.
//
// Ordered this way round because the row is what remembers there is anything to clean up. Delete it
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
// against. Keeping the account on failure is also the safer half of the trade — an account that still
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
if (OS_USERS_ENABLED && existing.osUser) {
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
if (!deprovisioned.ok) {
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
// and the freed range — which after a failed release is the only surviving record of it.
console.error(
`[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
(deprovisioned.freed
? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
` ${deprovisioned.freed.subUid?.count ?? ''}`
: ''),
);
throw errors.INTERNAL_SERVER_ERROR(
`Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
`The platform account was NOT deleted, so this can be retried.`,
);
}
for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
if (deprovisioned.freed) {
// The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
// `userdel` this log is the only place the freed subuid range still exists.
const { osUser, uid, subUid } = deprovisioned.freed;
console.info(
`[users] deprovisioned ${osUser} — verify with: sudo ./scripts/assert-uid-free.sh --check ` +
`${osUser} ${uid} ${subUid?.start ?? '<no-subuid-range>'} ${subUid?.count ?? ''}`,
);
}
}
// Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
// to them. There is no undo, which is why the UI asks first.
await deleteUser(id);
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'bun:test';
import { guardDeletable, guardMemberTree, parseSubUidEntry } from './os-user-deprovision';
// The guards that stand between a database column and `sudo userdel` / `sudo chown -R`.
//
// Everything else in os-user-deprovision.ts needs a machine with accounts to delete on. These three are pure
// precisely so the dangerous decisions can be tested without one — they are the part where being wrong is not
// recoverable, and the manual teardown that produced the spec is not a thing anyone should repeat to check a
// refactor.
describe('guardDeletable', () => {
const home = '/data/member@example.com/home';
test('accepts an account the platform made', () => {
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: home, expectedHome: home })).toEqual({ ok: true });
});
test('refuses root, and every other system account', () => {
// The scenario: a `users` row whose osUser column says 'root'. Nothing else in the sequence would stop it
// — `loginctl`, `pkill` and `userdel` would all simply do as they were told.
const result = guardDeletable({ osUser: 'root', uid: 0, passwdHome: '/root', expectedHome: home });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('system account');
expect(guardDeletable({ osUser: 'daemon', uid: 1, passwdHome: home, expectedHome: home }).ok).toBe(false);
expect(guardDeletable({ osUser: 'nobody', uid: 999, passwdHome: home, expectedHome: home }).ok).toBe(false);
});
test('refuses a same-named account that is not ours', () => {
// A human account that happens to share a member's username. Its home is its own, so it fails the only
// test that proves ownership. This is `ensureOsUser`'s adoption rule read backwards.
const result = guardDeletable({ osUser: 'green', uid: 1002, passwdHome: '/home/green', expectedHome: home });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('not an account the platform created');
});
test('refuses an account with no passwd home at all', () => {
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: null, expectedHome: home }).ok).toBe(false);
});
test('the uid floor is not sufficient on its own', () => {
// Both halves are required: a high uid with a foreign home is still somebody else's account.
expect(guardDeletable({ osUser: 'green', uid: 4000, passwdHome: '/srv/green', expectedHome: home }).ok).toBe(false);
});
});
describe('guardMemberTree', () => {
test('resolves a normal account directory', () => {
const result = guardMemberTree('/data', 'member@example.com');
expect(result).toEqual({ ok: true, tree: '/data/member@example.com' });
});
test('refuses traversal out of DATA_PATH', () => {
// The path this produces is the argument to `chown -R` and, under destroy, to a recursive delete. The
// email reaches join() from a database row.
expect(guardMemberTree('/data', '../../etc').ok).toBe(false);
expect(guardMemberTree('/data', '..').ok).toBe(false);
expect(guardMemberTree('/data', 'a/../../b').ok).toBe(false);
});
test('refuses a nested path even without traversal', () => {
// A member tree is one level down. Anything deeper is not an account directory, whatever it is.
expect(guardMemberTree('/data', 'a/b').ok).toBe(false);
});
test('refuses DATA_PATH itself', () => {
expect(guardMemberTree('/data', '').ok).toBe(false);
expect(guardMemberTree('/data', '.').ok).toBe(false);
});
test('refuses an empty DATA_PATH', () => {
// Unset DATA_PATH would otherwise make every member tree a path under the process's cwd.
expect(guardMemberTree('', 'member@example.com').ok).toBe(false);
});
});
describe('parseSubUidEntry', () => {
const file = 'pastilhas:100000:65536\ngreen:231072:65536\nofficer_jg:165536:65536\n';
test('reads the range for the named account only', () => {
expect(parseSubUidEntry(file, 'green')).toEqual({ start: 231072, count: 65536 });
expect(parseSubUidEntry(file, 'officer_jg')).toEqual({ start: 165536, count: 65536 });
});
test('a missing entry is null, not a guess', () => {
// Normal: rootless Docker is tolerated when it fails, and accounts predating it have no entry. A guessed
// range would make the audit check a different range than the one that was actually freed.
expect(parseSubUidEntry(file, 'nobody')).toBeNull();
expect(parseSubUidEntry('', 'green')).toBeNull();
});
test('does not match on a prefix', () => {
// 'green' must not match the 'green2' line — the ranges are different and the wrong one verifies nothing.
expect(parseSubUidEntry('green2:300000:65536\n', 'green')).toBeNull();
});
test('rejects a malformed line rather than producing NaN', () => {
// NaN would flow into `assert-uid-free.sh --check` as an argument and quietly scan nothing.
expect(parseSubUidEntry('green:notanumber:65536\n', 'green')).toBeNull();
expect(parseSubUidEntry('green:231072:0\n', 'green')).toBeNull();
});
});
+359
View File
@@ -0,0 +1,359 @@
import { rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { userInfo } from 'node:os';
import { join, resolve } from 'node:path';
import { DATA_PATH } from './data-path';
import { OS_USERS_ENABLED, lookupOsUser, osUserHome } from './os-user';
// Taking a member's Linux account away again.
//
// The specification, and every measured claim in it, is `docs/deprovision-os-account.md` — written from a
// manual teardown on the production host on 2026-08-11. Read it before changing the ORDER of anything here.
//
// ── Why this is not the mirror image of provisioning ──
//
// `provisionOsAccount` may fail freely: an account that did not get its Linux side is merely unusable, and
// the owner presses retry. This one cannot. `useradd` allocates the lowest free uid, so the moment `userdel`
// returns, that number is available to the next member — while the previous member's home, keys and
// container storage may still be owned by it. A half-finished deprovision does not leave a broken account;
// it leaves a **trap**, and the next person to create an account walks into it.
//
// Hence the one rule the sequence exists to enforce:
//
// Sever the data from the uid BEFORE releasing the uid. If severing fails, DO NOT release.
//
// The manual teardown got exactly this backwards — it ran `userdel` first and cleaned up afterwards — and
// the window it opened is the reason this file's steps are ordered rather than grouped.
//
// ── The subuid half ──
//
// A member's rootless Docker files are not owned by their uid. Container processes map through
// `/etc/subuid`, so on the production host `green`'s postgres data directory was owned by 231141
// (their range start + 70, postgres's uid inside the Alpine image). `userdel` frees the whole range along
// with the uid. So "nothing is owned by uid 1002" can be true while hundreds of megabytes are still owned by
// ids a future member's containers will map onto.
//
// `chown -R` fixes both at once — it rewrites every file it walks regardless of who owned it — which is why
// severing is a single operation rather than one pass per id space. But the range still has to be CAPTURED
// before `userdel`, because that is the last moment anyone can ask what it was. It is returned to the caller
// for exactly that reason; `scripts/assert-uid-free.sh --check` takes it as an argument.
/** What to do with the member's files. `preserve` is the default and the only one with a call site. */
export type DeprovisionPolicy = 'preserve' | 'destroy';
/**
* The identity of the account as it was before deletion.
*
* Returned even on failure when it was captured, because it is unrecoverable afterwards: `userdel` removes
* the `/etc/subuid` entry, and nothing on the machine then remembers which range the account held.
*/
export type FreedIdentity = {
osUser: string;
uid: number;
/** Null when the account had no `/etc/subuid` entry — rootless Docker was never provisioned for it. */
subUid: { start: number; count: number } | null;
};
export type DeprovisionResult =
| {
ok: true;
/** False when there was nothing to do — no Linux account by that name. Idempotent re-runs land here. */
removed: boolean;
freed: FreedIdentity | null;
/** Non-fatal residue. The uid is safe to reissue; something cosmetic outlived the teardown. */
warnings: string[];
}
| {
ok: false;
error: string;
/** Which step refused. `sever` in particular means the account is intact and MUST stay that way. */
stage: 'disabled' | 'guard' | 'reap' | 'sever' | 'release';
freed: FreedIdentity | null;
};
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
async function plain(args: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
/** The account this process runs as — who severed data is reassigned to. */
const serviceUser = (): string => userInfo().username;
/**
* The account's `/etc/subuid` range, read before anything is removed.
*
* Absent is normal, not an error: rootless Docker is provisioned per member but tolerated when it fails, and
* an account made before that existed has no entry at all.
*/
export function parseSubUidEntry(fileContent: string, osUser: string): { start: number; count: number } | null {
for (const line of fileContent.split('\n')) {
const [name, start, count] = line.split(':');
if (name !== osUser) continue;
const parsedStart = Number(start);
const parsedCount = Number(count);
if (!Number.isInteger(parsedStart) || !Number.isInteger(parsedCount) || parsedCount <= 0) return null;
return { start: parsedStart, count: parsedCount };
}
return null;
}
/**
* How many processes the uid still owns.
*
* `pgrep` exits 1 on no match, which is the expected answer here — so the exit code says nothing and the
* line count is the measurement.
*/
async function processCount(uid: number): Promise<number> {
const found = await plain(['pgrep', '-u', String(uid)]);
if (!found.out) return 0;
return found.out.split('\n').filter((line) => line.trim()).length;
}
/**
* Refuse to touch an account that is not ours to touch.
*
* This is `ensureOsUser`'s adoption rule pointed the other way, and it is the most important function in the
* file. Adoption asks "may I take this account over"; if it is wrong, a member gets somebody else's home.
* This asks "may I DELETE this account" — and if it is wrong, `userdel root` is a single sudo away, driven by
* a string in a database row.
*
* The passwd home is the proof of ownership. `ensureOsUser` refuses to adopt an account whose home is not the
* one it was going to confine, so any account the platform created or adopted has `home == osUserHome(email)`.
* Anything else on this machine, by any name, does not — including every system account.
*
* Exported so it can be tested against the cases that matter without a machine to delete users on.
*/
export function guardDeletable(params: {
osUser: string;
uid: number;
passwdHome: string | null;
expectedHome: string;
}): { ok: true } | { ok: false; error: string } {
if (params.uid < 1000) {
return { ok: false, error: `refusing to deprovision '${params.osUser}': uid ${params.uid} is a system account` };
}
if (params.passwdHome !== params.expectedHome) {
return {
ok: false,
error:
`refusing to deprovision '${params.osUser}': its home is ${params.passwdHome ?? 'unknown'}, not ` +
`${params.expectedHome}. This is not an account the platform created.`,
};
}
return { ok: true };
}
/**
* The member's tree must be a direct child of `DATA_PATH`, spelled with no traversal.
*
* The email arrives from a database row and reaches `join()`, so `../..` in it would walk out of `DATA_PATH`
* — and this path is the argument to a recursive `chown` and, under `destroy`, to a recursive delete. Cheap
* to assert, and the failure it prevents has no upper bound.
*/
export function guardMemberTree(
dataPath: string,
email: string,
): { ok: true; tree: string } | { ok: false; error: string } {
if (!dataPath) return { ok: false, error: 'DATA_PATH is empty; refusing to resolve a member tree' };
const tree = resolve(join(dataPath, email));
const parent = resolve(dataPath);
if (tree === parent || !tree.startsWith(`${parent}/`) || tree.slice(parent.length + 1).includes('/')) {
return { ok: false, error: `refusing to operate on ${tree}: not a direct child of ${parent}` };
}
return { ok: true, tree };
}
/**
* Remove a member's Linux account, severing their data from the uid first.
*
* Idempotent. An account that is already gone returns `{ ok: true, removed: false }`, and every step
* tolerates having been done before — so a partial failure can be retried by calling this again rather than
* by finishing it by hand.
*
* Never throws; returns a result. But unlike the provisioning functions, `ok: false` here is not
* cosmetic — see the note at the top of the file, and `stage` on the failure result.
*/
export async function deprovisionOsAccount(params: {
email: string;
osUser: string;
policy?: DeprovisionPolicy;
}): Promise<DeprovisionResult> {
const policy: DeprovisionPolicy = params.policy ?? 'preserve';
const warnings: string[] = [];
if (!OS_USERS_ENABLED) {
return {
ok: false,
stage: 'disabled',
freed: null,
error: 'per-user Linux accounts are not enabled on this server',
};
}
// Absent is success. This is what a re-run after a completed deprovision looks like, and what an account
// that never had a Linux side looks like — neither is a problem to report.
const ids = await lookupOsUser(params.osUser);
if (!ids) return { ok: true, removed: false, freed: null, warnings };
const passwd = await plain(['getent', 'passwd', params.osUser]);
const passwdHome = passwd.ok ? (passwd.out.split(':')[5] ?? null) : null;
const expectedHome = osUserHome(params.email);
const allowed = guardDeletable({ osUser: params.osUser, uid: ids.uid, passwdHome, expectedHome });
if (!allowed.ok) return { ok: false, stage: 'guard', freed: null, error: allowed.error };
const treeGuard = guardMemberTree(DATA_PATH, params.email);
if (!treeGuard.ok) return { ok: false, stage: 'guard', freed: null, error: treeGuard.error };
// ── Capture, before anything can destroy the evidence ──
//
// Read now because `userdel` removes the /etc/subuid entry with the account, and after that there is no way
// to ask what range it held. Carried on every return path below, including the failures, so an operator
// auditing a partial teardown still has the numbers `assert-uid-free.sh --check` needs.
const subuidFile = await sudo(['cat', '/etc/subuid']);
const freed: FreedIdentity = {
osUser: params.osUser,
uid: ids.uid,
subUid: subuidFile.ok ? parseSubUidEntry(subuidFile.out, params.osUser) : null,
};
if (!subuidFile.ok) warnings.push('could not read /etc/subuid; the freed range is unknown and unverifiable');
// ── 1. Linger off, before terminating anything ──
//
// Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring
// it straight back; disable first and nothing can respawn in the gap. A non-lingering account makes this a
// no-op, which is why its failure is a warning rather than a stop.
const linger = await sudo(['loginctl', 'disable-linger', params.osUser]);
if (!linger.ok) warnings.push(`disable-linger reported: ${linger.out}`);
// ── 2. Terminate, then PROVE it ──
//
// Measured on the production host: `terminate-user` is not a barrier. A three-hour-old `/bin/zsh -i` owned
// by the member survived it, and survived /run/user/<uid> being removed. `userdel` refuses while any
// process owned by the account lives, so trusting this call works on a quiet account and fails on a member
// who left a shell open — which is the normal case, not the edge one.
await sudo(['loginctl', 'terminate-user', params.osUser]);
const reaped = await reapProcesses(ids.uid);
if (!reaped.ok) return { ok: false, stage: 'reap', freed, error: reaped.error };
// ── 3. Sever the data from the uid, BEFORE releasing it ──
const severed = await severMemberTree({ tree: treeGuard.tree, policy });
if (!severed.ok) {
// Deliberately returns here rather than continuing. An account left intact is inert; a freed uid whose
// files are still owned by it is the hazard this whole file exists to prevent.
return { ok: false, stage: 'sever', freed, error: severed.error };
}
// ── 4. Release ──
//
// Never `-r`. It would delete the home, which contradicts `preserve` and would make `destroy` a consequence
// of a flag rather than of an explicit decision. Plain `userdel` was measured to remove the passwd, shadow
// and group entries and both the /etc/subuid and /etc/subgid ranges.
const del = await sudo(['userdel', params.osUser]);
if (!del.ok) {
return {
ok: false,
stage: 'release',
freed,
error:
`userdel failed for ${params.osUser}: ${del.out}. Their data has already been reassigned to ` +
`${serviceUser()}, so the account is inert — but it still exists. Retry.`,
};
}
// Residue: reported, not fatal. /run/user/<uid> is a tmpfs systemd normally reaps with the session; if it
// outlives one, it is empty, cleared at reboot, and no reason to call a correct teardown a failure.
if (existsSync(`/run/user/${ids.uid}`)) {
warnings.push(`/run/user/${ids.uid} still exists; it is tmpfs and clears on reboot`);
}
if (existsSync(`/var/lib/systemd/linger/${params.osUser}`)) {
warnings.push(`linger marker for ${params.osUser} survived disable-linger`);
}
return { ok: true, removed: true, freed, warnings };
}
/**
* Kill everything the uid owns, escalating, and refuse to return success while any of it lives.
*
* The bounded waits are the point. `pkill` returns as soon as the signal is delivered, not when the process
* has gone, so a check that follows it immediately reads the state before the kill took effect.
*/
async function reapProcesses(uid: number): Promise<{ ok: true } | { ok: false; error: string }> {
// Re-asserted here rather than trusted from the caller. This is the one function that signals by uid alone
// — the name is not in the argv — so if the number were ever wrong, it would be wrong about somebody else's
// processes with nothing else to catch it.
if (uid < 1000) return { ok: false, error: `refusing to signal uid ${uid}: not a member account` };
if ((await processCount(uid)) === 0) return { ok: true };
await sudo(['pkill', '-u', String(uid)]);
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
await Bun.sleep(200);
}
if ((await processCount(uid)) > 0) {
await sudo(['pkill', '-9', '-u', String(uid)]);
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
await Bun.sleep(200);
}
}
const remaining = await processCount(uid);
if (remaining > 0) {
return {
ok: false,
error: `${remaining} process(es) owned by uid ${uid} survived SIGKILL; not releasing the account`,
};
}
return { ok: true };
}
/**
* Break the link between the member's files and their uid.
*
* `preserve` reassigns; `destroy` reassigns and then deletes. Destroying via reassignment rather than via
* `sudo rm -rf` is deliberate: after the chown the service user owns every byte and can delete the tree
* itself, so a recursive delete as root — with a path built from a database column — never has to exist in
* this codebase.
*
* `-h` because a symlink must be re-owned rather than followed. Measured on this host: `chown -R` already
* behaves this way (a symlink pointing outside the tree was left untouched, and the link's own ownership was
* rewritten), but the flag states it in the argv instead of resting on traversal semantics nobody documented
* — and if it ever did follow, the target would be whatever a member chose to point at.
*
* Re-owning the symlinks is also what makes the audit meaningful: `find -uid` uses `lstat`, so a link left
* owned by the freed uid is a failing check.
*/
async function severMemberTree(params: {
tree: string;
policy: DeprovisionPolicy;
}): Promise<{ ok: true } | { ok: false; error: string }> {
// Nothing to sever is success — an account whose directories were already removed by hand still needs its
// passwd entry released, and refusing here would leave that undone forever.
if (!existsSync(params.tree)) return { ok: true };
const who = serviceUser();
const chowned = await sudo(['chown', '-h', '-R', `${who}:${who}`, params.tree]);
if (!chowned.ok) return { ok: false, error: `could not reassign ${params.tree} to ${who}: ${chowned.out}` };
if (params.policy === 'preserve') return { ok: true };
try {
await rm(params.tree, { recursive: true, force: true });
} catch (ex) {
return {
ok: false,
error: `reassigned ${params.tree} but could not delete it: ${ex instanceof Error ? ex.message : String(ex)}`,
};
}
return { ok: true };
}