120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
/**
|
|
* tree-permissions.ts — make a copied tree readable by whoever owns the workspace.
|
|
*
|
|
* Files captured from a task run can arrive owned by another user, or with a
|
|
* directory missing the permission needed to walk into it. Packaging then fails
|
|
* with `Cannot stat: Permission denied`. This repairs both.
|
|
*
|
|
* Grants owner rwX only, never group or other. Never throws, and never hands
|
|
* files to root. Set `RACCOON_SKIP_PERMISSION_REPAIR=1` to turn it off.
|
|
*/
|
|
import { chmodSync, chownSync, lstatSync, readdirSync, statSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
export interface NormalizeReport {
|
|
/** Paths whose owner was changed. */
|
|
ownerFixed: string[];
|
|
/** Paths whose mode gained owner rwX. */
|
|
modeFixed: string[];
|
|
/** Paths we wanted to change but could not, with the errno. */
|
|
failures: { path: string; reason: string }[];
|
|
/** Resolved target owner, or null if it couldn't be determined. */
|
|
target: { uid: number; gid: number } | null;
|
|
/** Set when disabled via RACCOON_SKIP_PERMISSION_REPAIR. */
|
|
skipped?: boolean;
|
|
}
|
|
|
|
/** Owner a workspace tree should have: whoever owns `ownerRef`. */
|
|
export function resolveWorkspaceOwner(ownerRef: string): { uid: number; gid: number } | null {
|
|
try {
|
|
const st = statSync(ownerRef);
|
|
return { uid: st.uid, gid: st.gid };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Owner-rwX mode, preserving every other bit. Dirs also need the search bit. */
|
|
function withOwnerAccess(mode: number, isDir: boolean): number {
|
|
return mode | (isDir ? 0o700 : 0o600);
|
|
}
|
|
|
|
/**
|
|
* Give every entry under `root` to the workspace owner and make sure that owner
|
|
* can read and traverse it. Symlinks are skipped. Repairs what it can and
|
|
* reports what it couldn't; it never throws and never blocks its caller.
|
|
*/
|
|
export function normalizeTreePermissions(
|
|
root: string,
|
|
options: { ownerRef?: string } = {}
|
|
): NormalizeReport {
|
|
if (process.env.RACCOON_SKIP_PERMISSION_REPAIR === '1') {
|
|
return { ownerFixed: [], modeFixed: [], failures: [], target: null, skipped: true };
|
|
}
|
|
|
|
const target = resolveWorkspaceOwner(options.ownerRef ?? process.cwd());
|
|
const report: NormalizeReport = { ownerFixed: [], modeFixed: [], failures: [], target };
|
|
|
|
// Never hand files to root — that would lock the owner out rather than help.
|
|
const chownTarget = target && target.uid !== 0 ? target : null;
|
|
|
|
try {
|
|
const stack: string[] = [root];
|
|
while (stack.length > 0) {
|
|
const path = stack.pop() as string;
|
|
|
|
let st;
|
|
try {
|
|
st = lstatSync(path);
|
|
} catch (err) {
|
|
report.failures.push({ path, reason: (err as NodeJS.ErrnoException).code ?? 'ELSTAT' });
|
|
continue;
|
|
}
|
|
if (st.isSymbolicLink()) continue;
|
|
|
|
const isDir = st.isDirectory();
|
|
|
|
// Mode first: a directory we can't search is one we can't descend into.
|
|
const wanted = withOwnerAccess(st.mode, isDir);
|
|
if (wanted !== st.mode) {
|
|
try {
|
|
chmodSync(path, wanted);
|
|
report.modeFixed.push(path);
|
|
} catch (err) {
|
|
report.failures.push({ path, reason: (err as NodeJS.ErrnoException).code ?? 'ECHMOD' });
|
|
}
|
|
}
|
|
|
|
if (chownTarget && (st.uid !== chownTarget.uid || st.gid !== chownTarget.gid)) {
|
|
try {
|
|
chownSync(path, chownTarget.uid, chownTarget.gid);
|
|
report.ownerFixed.push(path);
|
|
} catch (err) {
|
|
report.failures.push({ path, reason: (err as NodeJS.ErrnoException).code ?? 'ECHOWN' });
|
|
}
|
|
}
|
|
|
|
if (!isDir) continue;
|
|
try {
|
|
for (const entry of readdirSync(path)) stack.push(join(path, entry));
|
|
} catch (err) {
|
|
report.failures.push({ path, reason: (err as NodeJS.ErrnoException).code ?? 'EREADDIR' });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
report.failures.push({ path: root, reason: (err as NodeJS.ErrnoException).code ?? 'EWALK' });
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
/** True when something was actually repaired. */
|
|
export function didRepair(report: NormalizeReport): boolean {
|
|
return report.ownerFixed.length > 0 || report.modeFixed.length > 0;
|
|
}
|
|
|
|
/** The command to run on your host if we couldn't fix it ourselves. */
|
|
export function manualRepairHint(path: string): string {
|
|
return `sudo chown -R "$(id -un):$(id -gn)" ${path} && chmod -R u+rwX ${path}`;
|
|
}
|