@soulguard/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/cli.js +8838 -0
- package/dist/index.js +6555 -0
- package/package.json +35 -0
- package/src/approve.test.ts +386 -0
- package/src/approve.ts +352 -0
- package/src/cli/approve-command.ts +107 -0
- package/src/cli/diff-command.test.ts +61 -0
- package/src/cli/diff-command.ts +81 -0
- package/src/cli/init-command.ts +83 -0
- package/src/cli/reset-command.ts +36 -0
- package/src/cli/status-command.test.ts +90 -0
- package/src/cli/status-command.ts +78 -0
- package/src/cli/sync-command.test.ts +67 -0
- package/src/cli/sync-command.ts +105 -0
- package/src/cli.ts +224 -0
- package/src/console-live.ts +32 -0
- package/src/console-mock.ts +48 -0
- package/src/console.ts +12 -0
- package/src/constants.ts +21 -0
- package/src/diff.test.ts +189 -0
- package/src/diff.ts +212 -0
- package/src/git.test.ts +180 -0
- package/src/git.ts +131 -0
- package/src/glob.test.ts +74 -0
- package/src/glob.ts +84 -0
- package/src/index.ts +100 -0
- package/src/init.test.ts +234 -0
- package/src/init.ts +317 -0
- package/src/policy.test.ts +123 -0
- package/src/policy.ts +100 -0
- package/src/reset.test.ts +68 -0
- package/src/reset.ts +63 -0
- package/src/result.ts +14 -0
- package/src/schema.test.ts +27 -0
- package/src/schema.ts +22 -0
- package/src/self-protection.test.ts +139 -0
- package/src/self-protection.ts +63 -0
- package/src/status.test.ts +241 -0
- package/src/status.ts +114 -0
- package/src/sync.test.ts +172 -0
- package/src/sync.ts +101 -0
- package/src/system-ops-mock.ts +243 -0
- package/src/system-ops-node.test.ts +183 -0
- package/src/system-ops-node.ts +499 -0
- package/src/system-ops.ts +109 -0
- package/src/types.ts +91 -0
- package/src/vault-check.test.ts +41 -0
- package/src/vault-check.ts +24 -0
- package/test-e2e/Dockerfile +29 -0
- package/test-e2e/cases/approve-with-hash/expected.txt +16 -0
- package/test-e2e/cases/approve-with-hash/test.sh +23 -0
- package/test-e2e/cases/diff-no-changes/expected.txt +5 -0
- package/test-e2e/cases/diff-no-changes/test.sh +7 -0
- package/test-e2e/cases/diff-no-staging/expected.txt +1 -0
- package/test-e2e/cases/diff-no-staging/test.sh +6 -0
- package/test-e2e/cases/diff-shows-changes/expected.txt +13 -0
- package/test-e2e/cases/diff-shows-changes/test.sh +12 -0
- package/test-e2e/cases/diff-vault-missing/expected.txt +6 -0
- package/test-e2e/cases/diff-vault-missing/test.sh +10 -0
- package/test-e2e/cases/glob-ledger-files/expected.txt +52 -0
- package/test-e2e/cases/glob-ledger-files/test.sh +28 -0
- package/test-e2e/cases/glob-vault-files/expected.txt +41 -0
- package/test-e2e/cases/glob-vault-files/test.sh +30 -0
- package/test-e2e/cases/init-blocked-by-agent/expected.txt +11 -0
- package/test-e2e/cases/init-blocked-by-agent/test.sh +15 -0
- package/test-e2e/cases/init-happy/expected.txt +18 -0
- package/test-e2e/cases/init-happy/test.sh +20 -0
- package/test-e2e/cases/init-idempotent/expected.txt +13 -0
- package/test-e2e/cases/init-idempotent/test.sh +14 -0
- package/test-e2e/cases/reset-staging/expected.txt +16 -0
- package/test-e2e/cases/reset-staging/test.sh +23 -0
- package/test-e2e/cases/self-protection-blocks-invalid/expected.txt +12 -0
- package/test-e2e/cases/self-protection-blocks-invalid/test.sh +25 -0
- package/test-e2e/cases/status-clean/expected.txt +9 -0
- package/test-e2e/cases/status-clean/test.sh +8 -0
- package/test-e2e/cases/status-drifted/expected.txt +9 -0
- package/test-e2e/cases/status-drifted/test.sh +11 -0
- package/test-e2e/cases/status-no-config/expected.txt +1 -0
- package/test-e2e/cases/status-no-config/test.sh +2 -0
- package/test-e2e/cases/sync-fix-drift/expected.txt +15 -0
- package/test-e2e/cases/sync-fix-drift/test.sh +14 -0
- package/test-e2e/cases/sync-no-sudo-clean/expected.txt +11 -0
- package/test-e2e/cases/sync-no-sudo-clean/test.sh +9 -0
- package/test-e2e/cases/sync-no-sudo-drift/expected.txt +7 -0
- package/test-e2e/cases/sync-no-sudo-drift/test.sh +10 -0
- package/test-e2e/cases/vault-remove-file/expected.txt +26 -0
- package/test-e2e/cases/vault-remove-file/test.sh +31 -0
- package/test-e2e/cases/vault-write-blocked/expected.txt +8 -0
- package/test-e2e/cases/vault-write-blocked/test.sh +14 -0
- package/test-e2e/run-tests.sh +76 -0
- package/test-integration/Dockerfile +17 -0
- package/test-integration/system-ops-node.integration.test.ts +170 -0
- package/tsconfig.json +8 -0
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sync } from "./sync.js";
|
|
3
|
+
import { MockSystemOps } from "./system-ops-mock.js";
|
|
4
|
+
|
|
5
|
+
const WORKSPACE = "/test/workspace";
|
|
6
|
+
const VAULT_OWNERSHIP = { user: "soulguardian", group: "soulguard", mode: "444" };
|
|
7
|
+
const LEDGER_OWNERSHIP = { user: "aster", group: "staff", mode: "644" };
|
|
8
|
+
|
|
9
|
+
function makeMock() {
|
|
10
|
+
const ops = new MockSystemOps(WORKSPACE);
|
|
11
|
+
ops.addUser(VAULT_OWNERSHIP.user);
|
|
12
|
+
ops.addGroup(VAULT_OWNERSHIP.group);
|
|
13
|
+
return ops;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function opts(config: { vault: string[]; ledger: string[] }, ops: MockSystemOps) {
|
|
17
|
+
return {
|
|
18
|
+
config,
|
|
19
|
+
expectedVaultOwnership: VAULT_OWNERSHIP,
|
|
20
|
+
expectedLedgerOwnership: LEDGER_OWNERSHIP,
|
|
21
|
+
ops,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("sync", () => {
|
|
26
|
+
test("fixes unprotected vault files", async () => {
|
|
27
|
+
const ops = makeMock();
|
|
28
|
+
ops.addFile("SOUL.md", "# Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
29
|
+
|
|
30
|
+
const result = await sync(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
|
|
31
|
+
expect(result.ok).toBe(true);
|
|
32
|
+
if (!result.ok) return;
|
|
33
|
+
|
|
34
|
+
// Before had issues
|
|
35
|
+
expect(result.value.before.issues).toHaveLength(1);
|
|
36
|
+
// After is clean
|
|
37
|
+
expect(result.value.after.issues).toHaveLength(0);
|
|
38
|
+
expect(result.value.errors).toHaveLength(0);
|
|
39
|
+
expect(ops.ops).toHaveLength(2); // chown + chmod
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("no-op when already protected", async () => {
|
|
43
|
+
const ops = makeMock();
|
|
44
|
+
ops.addFile("SOUL.md", "# Soul", {
|
|
45
|
+
owner: VAULT_OWNERSHIP.user,
|
|
46
|
+
group: VAULT_OWNERSHIP.group,
|
|
47
|
+
mode: "444",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await sync(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
|
|
51
|
+
expect(result.ok).toBe(true);
|
|
52
|
+
if (!result.ok) return;
|
|
53
|
+
|
|
54
|
+
expect(result.value.before.issues).toHaveLength(0);
|
|
55
|
+
expect(result.value.after.issues).toHaveLength(0);
|
|
56
|
+
expect(result.value.errors).toHaveLength(0);
|
|
57
|
+
expect(ops.ops).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("missing files remain in issues (can't fix)", async () => {
|
|
61
|
+
const ops = makeMock();
|
|
62
|
+
|
|
63
|
+
const result = await sync(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
|
|
64
|
+
expect(result.ok).toBe(true);
|
|
65
|
+
if (!result.ok) return;
|
|
66
|
+
|
|
67
|
+
expect(result.value.before.issues).toHaveLength(1);
|
|
68
|
+
expect(result.value.after.issues).toHaveLength(1); // still missing
|
|
69
|
+
expect(result.value.errors).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("fixes only what needs fixing", async () => {
|
|
73
|
+
const ops = makeMock();
|
|
74
|
+
ops.addFile("SOUL.md", "# Soul", {
|
|
75
|
+
owner: VAULT_OWNERSHIP.user,
|
|
76
|
+
group: VAULT_OWNERSHIP.group,
|
|
77
|
+
mode: "644",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await sync(opts({ vault: ["SOUL.md"], ledger: [] }, ops));
|
|
81
|
+
expect(result.ok).toBe(true);
|
|
82
|
+
if (!result.ok) return;
|
|
83
|
+
|
|
84
|
+
expect(result.value.after.issues).toHaveLength(0);
|
|
85
|
+
expect(ops.ops).toHaveLength(1); // only chmod
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("releases ledger files owned by soulguardian", async () => {
|
|
89
|
+
const ops = makeMock();
|
|
90
|
+
ops.addFile("notes.md", "# Notes", {
|
|
91
|
+
owner: VAULT_OWNERSHIP.user,
|
|
92
|
+
group: VAULT_OWNERSHIP.group,
|
|
93
|
+
mode: "444",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await sync(opts({ vault: [], ledger: ["notes.md"] }, ops));
|
|
97
|
+
expect(result.ok).toBe(true);
|
|
98
|
+
if (!result.ok) return;
|
|
99
|
+
|
|
100
|
+
expect(result.value.before.issues).toHaveLength(1);
|
|
101
|
+
expect(result.value.after.issues).toHaveLength(0);
|
|
102
|
+
expect(ops.ops).toHaveLength(2); // chown + chmod
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("handles multiple files across tiers", async () => {
|
|
106
|
+
const ops = makeMock();
|
|
107
|
+
ops.addFile("SOUL.md", "# Soul", { owner: "agent", group: "staff", mode: "644" });
|
|
108
|
+
ops.addFile("AGENTS.md", "# Agents", {
|
|
109
|
+
owner: VAULT_OWNERSHIP.user,
|
|
110
|
+
group: VAULT_OWNERSHIP.group,
|
|
111
|
+
mode: "444",
|
|
112
|
+
});
|
|
113
|
+
ops.addFile("notes.md", "# Notes", {
|
|
114
|
+
owner: VAULT_OWNERSHIP.user,
|
|
115
|
+
group: VAULT_OWNERSHIP.group,
|
|
116
|
+
mode: "444",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const result = await sync(opts({ vault: ["SOUL.md", "AGENTS.md"], ledger: ["notes.md"] }, ops));
|
|
120
|
+
expect(result.ok).toBe(true);
|
|
121
|
+
if (!result.ok) return;
|
|
122
|
+
|
|
123
|
+
// Before: SOUL.md drifted (vault), notes.md drifted (ledger)
|
|
124
|
+
expect(result.value.before.issues).toHaveLength(2);
|
|
125
|
+
// After: all clean
|
|
126
|
+
expect(result.value.after.issues).toHaveLength(0);
|
|
127
|
+
expect(result.value.errors).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("commits vault and ledger files to git when enabled", async () => {
|
|
131
|
+
const ops = makeMock();
|
|
132
|
+
ops.addFile(".git", "");
|
|
133
|
+
ops.addFile("SOUL.md", "# Soul", {
|
|
134
|
+
owner: VAULT_OWNERSHIP.user,
|
|
135
|
+
group: VAULT_OWNERSHIP.group,
|
|
136
|
+
mode: "444",
|
|
137
|
+
});
|
|
138
|
+
ops.addFile("notes.md", "# Notes", {
|
|
139
|
+
owner: LEDGER_OWNERSHIP.user,
|
|
140
|
+
group: LEDGER_OWNERSHIP.group,
|
|
141
|
+
mode: LEDGER_OWNERSHIP.mode,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Make post-add diff check fail (= files staged)
|
|
145
|
+
ops.execFailOnCall.set("git diff --cached --quiet", new Set([1]));
|
|
146
|
+
|
|
147
|
+
const result = await sync(
|
|
148
|
+
opts({ vault: ["SOUL.md"], ledger: ["notes.md"], git: true } as never, ops),
|
|
149
|
+
);
|
|
150
|
+
expect(result.ok).toBe(true);
|
|
151
|
+
if (!result.ok) return;
|
|
152
|
+
expect(result.value.git).toBeDefined();
|
|
153
|
+
expect(result.value.git?.committed).toBe(true);
|
|
154
|
+
if (result.value.git?.committed) {
|
|
155
|
+
expect(result.value.git.files).toEqual(["SOUL.md", "notes.md"]);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("skips git commit when git disabled", async () => {
|
|
160
|
+
const ops = makeMock();
|
|
161
|
+
ops.addFile("SOUL.md", "# Soul", {
|
|
162
|
+
owner: VAULT_OWNERSHIP.user,
|
|
163
|
+
group: VAULT_OWNERSHIP.group,
|
|
164
|
+
mode: "444",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await sync(opts({ vault: ["SOUL.md"], ledger: [], git: false } as never, ops));
|
|
168
|
+
expect(result.ok).toBe(true);
|
|
169
|
+
if (!result.ok) return;
|
|
170
|
+
expect(result.value.git).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* soulguard sync — fix all issues found by status.
|
|
3
|
+
*
|
|
4
|
+
* Runs status before and after applying fixes. The diff between
|
|
5
|
+
* before and after IS what happened. Errors are explicit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DriftIssue, FileSystemError, IOError } from "./types.js";
|
|
9
|
+
import { ok } from "./result.js";
|
|
10
|
+
import type { Result } from "./result.js";
|
|
11
|
+
import { status } from "./status.js";
|
|
12
|
+
import type { StatusOptions, StatusResult } from "./status.js";
|
|
13
|
+
import { isGitEnabled, gitCommit } from "./git.js";
|
|
14
|
+
import type { GitCommitResult } from "./git.js";
|
|
15
|
+
import { resolvePatterns } from "./glob.js";
|
|
16
|
+
|
|
17
|
+
export type SyncError = {
|
|
18
|
+
path: string;
|
|
19
|
+
operation: string;
|
|
20
|
+
error: FileSystemError;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SyncResult = {
|
|
24
|
+
before: StatusResult;
|
|
25
|
+
after: StatusResult;
|
|
26
|
+
errors: SyncError[];
|
|
27
|
+
/** Git commit result (best-effort, only when git enabled) */
|
|
28
|
+
git?: GitCommitResult;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SyncOptions = StatusOptions;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run status, fix drifted files, run status again.
|
|
35
|
+
*/
|
|
36
|
+
export async function sync(options: SyncOptions): Promise<Result<SyncResult, IOError>> {
|
|
37
|
+
const { ops } = options;
|
|
38
|
+
|
|
39
|
+
const beforeResult = await status(options);
|
|
40
|
+
if (!beforeResult.ok) return beforeResult;
|
|
41
|
+
const before = beforeResult.value;
|
|
42
|
+
|
|
43
|
+
const errors: SyncError[] = [];
|
|
44
|
+
|
|
45
|
+
for (const issue of before.issues) {
|
|
46
|
+
if (issue.status !== "drifted") continue;
|
|
47
|
+
|
|
48
|
+
const expectedOwnership =
|
|
49
|
+
issue.tier === "vault" ? options.expectedVaultOwnership : options.expectedLedgerOwnership;
|
|
50
|
+
|
|
51
|
+
const path = issue.file.path;
|
|
52
|
+
const needsChown = issue.issues.some(
|
|
53
|
+
(i: DriftIssue) => i.kind === "wrong_owner" || i.kind === "wrong_group",
|
|
54
|
+
);
|
|
55
|
+
const needsChmod = issue.issues.some((i: DriftIssue) => i.kind === "wrong_mode");
|
|
56
|
+
|
|
57
|
+
if (needsChown) {
|
|
58
|
+
const { user, group } = expectedOwnership;
|
|
59
|
+
const result = await ops.chown(path, { user, group });
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
errors.push({ path, operation: "chown", error: result.error });
|
|
62
|
+
// Short-circuit: if chown fails, chmod will almost certainly fail too.
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (needsChmod) {
|
|
68
|
+
const result = await ops.chmod(path, expectedOwnership.mode);
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
errors.push({ path, operation: "chmod", error: result.error });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const afterResult = await status(options);
|
|
77
|
+
if (!afterResult.ok) return afterResult;
|
|
78
|
+
const after = afterResult.value;
|
|
79
|
+
|
|
80
|
+
// Best-effort git commit of all tracked files (vault + ledger)
|
|
81
|
+
let git: GitCommitResult | undefined;
|
|
82
|
+
if (await isGitEnabled(ops, options.config)) {
|
|
83
|
+
const [vaultResolved, ledgerResolved] = await Promise.all([
|
|
84
|
+
resolvePatterns(ops, options.config.vault),
|
|
85
|
+
resolvePatterns(ops, options.config.ledger),
|
|
86
|
+
]);
|
|
87
|
+
const allFiles = [
|
|
88
|
+
...(vaultResolved.ok ? vaultResolved.value : []),
|
|
89
|
+
...(ledgerResolved.ok ? ledgerResolved.value : []),
|
|
90
|
+
];
|
|
91
|
+
if (allFiles.length > 0) {
|
|
92
|
+
const gitResult = await gitCommit(ops, allFiles, "soulguard: sync");
|
|
93
|
+
if (gitResult.ok) {
|
|
94
|
+
git = gitResult.value;
|
|
95
|
+
}
|
|
96
|
+
// Git errors swallowed — best-effort
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return ok({ before, after, errors, git });
|
|
101
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock SystemOperations for testing.
|
|
3
|
+
*
|
|
4
|
+
* Takes a workspace root; all paths are relative (resolved internally).
|
|
5
|
+
* Records all mutation operations for assertion.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type { FileStat, SystemOperations } from "./system-ops.js";
|
|
10
|
+
import type { Result, NotFoundError, PermissionDeniedError, IOError } from "./types.js";
|
|
11
|
+
import { matchGlob } from "./glob.js";
|
|
12
|
+
import { ok, err } from "./result.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Records what the mock *did*. Intentionally parallel to SyncAction
|
|
16
|
+
* (which records what sync *decided*) — they track different things.
|
|
17
|
+
*/
|
|
18
|
+
export type RecordedOp =
|
|
19
|
+
| { kind: "chown"; path: string; owner: { user: string; group: string } }
|
|
20
|
+
| { kind: "chmod"; path: string; mode: string }
|
|
21
|
+
| { kind: "exec"; command: string; args: string[] };
|
|
22
|
+
|
|
23
|
+
type MockFile = {
|
|
24
|
+
content: string;
|
|
25
|
+
owner: string;
|
|
26
|
+
group: string;
|
|
27
|
+
mode: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class MockSystemOps implements SystemOperations {
|
|
31
|
+
public readonly workspace: string;
|
|
32
|
+
private files: Map<string, MockFile> = new Map();
|
|
33
|
+
private users: Set<string> = new Set();
|
|
34
|
+
private groups: Set<string> = new Set();
|
|
35
|
+
public ops: RecordedOp[] = [];
|
|
36
|
+
/** Commands that should fail (for testing error paths). Key: "command arg1 arg2" */
|
|
37
|
+
public failingExecs: Set<string> = new Set();
|
|
38
|
+
public failingDeletes: Set<string> = new Set();
|
|
39
|
+
/** Per-command call counters for execFailAfter/execFailBefore */
|
|
40
|
+
private execCallCounts: Map<string, number> = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* Commands that should fail only on the Nth call (0-indexed).
|
|
43
|
+
* Key: "command arg1 arg2", Value: set of call indices that should fail.
|
|
44
|
+
*/
|
|
45
|
+
public execFailOnCall: Map<string, Set<number>> = new Map();
|
|
46
|
+
|
|
47
|
+
constructor(workspace: string) {
|
|
48
|
+
this.workspace = workspace;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private resolve(path: string): string {
|
|
52
|
+
return resolve(this.workspace, path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Add a simulated file (relative path) */
|
|
56
|
+
addFile(
|
|
57
|
+
path: string,
|
|
58
|
+
content: string,
|
|
59
|
+
opts: { owner?: string; group?: string; mode?: string } = {},
|
|
60
|
+
): void {
|
|
61
|
+
this.files.set(this.resolve(path), {
|
|
62
|
+
content,
|
|
63
|
+
owner: opts.owner ?? "unknown",
|
|
64
|
+
group: opts.group ?? "unknown",
|
|
65
|
+
mode: opts.mode ?? "644",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Add a simulated system user */
|
|
70
|
+
addUser(name: string): void {
|
|
71
|
+
this.users.add(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Add a simulated system group */
|
|
75
|
+
addGroup(name: string): void {
|
|
76
|
+
this.groups.add(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async userExists(name: string): Promise<Result<boolean, IOError>> {
|
|
80
|
+
return ok(this.users.has(name));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async groupExists(name: string): Promise<Result<boolean, IOError>> {
|
|
84
|
+
return ok(this.groups.has(name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async stat(
|
|
88
|
+
path: string,
|
|
89
|
+
): Promise<Result<FileStat, NotFoundError | PermissionDeniedError | IOError>> {
|
|
90
|
+
const full = this.resolve(path);
|
|
91
|
+
const file = this.files.get(full);
|
|
92
|
+
if (!file) return err({ kind: "not_found", path });
|
|
93
|
+
return ok({
|
|
94
|
+
path,
|
|
95
|
+
ownership: { user: file.owner, group: file.group, mode: file.mode },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async chown(
|
|
100
|
+
path: string,
|
|
101
|
+
owner: { user: string; group: string },
|
|
102
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
103
|
+
const full = this.resolve(path);
|
|
104
|
+
const file = this.files.get(full);
|
|
105
|
+
if (!file) return err({ kind: "not_found", path });
|
|
106
|
+
this.ops.push({ kind: "chown", path, owner });
|
|
107
|
+
file.owner = owner.user;
|
|
108
|
+
file.group = owner.group;
|
|
109
|
+
return ok(undefined);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async chmod(
|
|
113
|
+
path: string,
|
|
114
|
+
mode: string,
|
|
115
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
116
|
+
const full = this.resolve(path);
|
|
117
|
+
const file = this.files.get(full);
|
|
118
|
+
if (!file) return err({ kind: "not_found", path });
|
|
119
|
+
this.ops.push({ kind: "chmod", path, mode });
|
|
120
|
+
file.mode = mode;
|
|
121
|
+
return ok(undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async readFile(
|
|
125
|
+
path: string,
|
|
126
|
+
): Promise<Result<string, NotFoundError | PermissionDeniedError | IOError>> {
|
|
127
|
+
const full = this.resolve(path);
|
|
128
|
+
const file = this.files.get(full);
|
|
129
|
+
if (!file) return err({ kind: "not_found", path });
|
|
130
|
+
return ok(file.content);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async createUser(name: string, _group: string): Promise<Result<void, IOError>> {
|
|
134
|
+
this.users.add(name);
|
|
135
|
+
return ok(undefined);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async createGroup(name: string): Promise<Result<void, IOError>> {
|
|
139
|
+
this.groups.add(name);
|
|
140
|
+
return ok(undefined);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async writeFile(
|
|
144
|
+
path: string,
|
|
145
|
+
content: string,
|
|
146
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
147
|
+
const full = this.resolve(path);
|
|
148
|
+
this.files.set(full, { content, owner: "agent", group: "staff", mode: "644" });
|
|
149
|
+
return ok(undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async mkdir(
|
|
153
|
+
path: string,
|
|
154
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
155
|
+
// Track directory and all parent dirs as entries (mirrors recursive mkdir)
|
|
156
|
+
const full = this.resolve(path);
|
|
157
|
+
if (!this.files.has(full)) {
|
|
158
|
+
this.files.set(full, { content: "", owner: "root", group: "root", mode: "755" });
|
|
159
|
+
}
|
|
160
|
+
// Also create parent directories
|
|
161
|
+
const parts = path.split("/");
|
|
162
|
+
for (let i = 1; i < parts.length; i++) {
|
|
163
|
+
const parent = parts.slice(0, i).join("/");
|
|
164
|
+
const parentFull = this.resolve(parent);
|
|
165
|
+
if (!this.files.has(parentFull)) {
|
|
166
|
+
this.files.set(parentFull, { content: "", owner: "root", group: "root", mode: "755" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return ok(undefined);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async copyFile(
|
|
173
|
+
src: string,
|
|
174
|
+
dest: string,
|
|
175
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
176
|
+
const fullSrc = this.resolve(src);
|
|
177
|
+
const file = this.files.get(fullSrc);
|
|
178
|
+
if (!file) return err({ kind: "not_found", path: src });
|
|
179
|
+
const fullDest = this.resolve(dest);
|
|
180
|
+
this.files.set(fullDest, { ...file });
|
|
181
|
+
return ok(undefined);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async exists(path: string): Promise<Result<boolean, IOError>> {
|
|
185
|
+
const full = this.resolve(path);
|
|
186
|
+
return ok(this.files.has(full));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async deleteFile(
|
|
190
|
+
path: string,
|
|
191
|
+
): Promise<Result<void, NotFoundError | PermissionDeniedError | IOError>> {
|
|
192
|
+
const full = this.resolve(path);
|
|
193
|
+
if (this.failingDeletes.has(path) || this.failingDeletes.has(full)) {
|
|
194
|
+
return err({ kind: "permission_denied", path, operation: "unlink" });
|
|
195
|
+
}
|
|
196
|
+
if (!this.files.has(full)) return err({ kind: "not_found", path });
|
|
197
|
+
this.files.delete(full);
|
|
198
|
+
return ok(undefined);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async hashFile(
|
|
202
|
+
path: string,
|
|
203
|
+
): Promise<Result<string, NotFoundError | PermissionDeniedError | IOError>> {
|
|
204
|
+
const full = this.resolve(path);
|
|
205
|
+
const file = this.files.get(full);
|
|
206
|
+
if (!file) return err({ kind: "not_found", path });
|
|
207
|
+
const hash = new Bun.CryptoHasher("sha256");
|
|
208
|
+
hash.update(file.content);
|
|
209
|
+
return ok(hash.digest("hex"));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async glob(pattern: string): Promise<Result<string[], IOError>> {
|
|
213
|
+
// Simple glob matching against mock filesystem
|
|
214
|
+
const prefix = this.workspace + "/";
|
|
215
|
+
const matches: string[] = [];
|
|
216
|
+
|
|
217
|
+
for (const fullPath of this.files.keys()) {
|
|
218
|
+
if (!fullPath.startsWith(prefix)) continue;
|
|
219
|
+
const relPath = fullPath.slice(prefix.length);
|
|
220
|
+
if (matchGlob(pattern, relPath)) {
|
|
221
|
+
matches.push(relPath);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return ok(matches.sort());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async exec(command: string, args: string[]): Promise<Result<void, IOError>> {
|
|
229
|
+
this.ops.push({ kind: "exec", command, args });
|
|
230
|
+
const key = [command, ...args].join(" ");
|
|
231
|
+
if (this.failingExecs.has(key)) {
|
|
232
|
+
return err({ kind: "io_error", path: "", message: `${key} failed` });
|
|
233
|
+
}
|
|
234
|
+
// Support call-index-specific failures
|
|
235
|
+
const callIndex = this.execCallCounts.get(key) ?? 0;
|
|
236
|
+
this.execCallCounts.set(key, callIndex + 1);
|
|
237
|
+
const failIndices = this.execFailOnCall.get(key);
|
|
238
|
+
if (failIndices?.has(callIndex)) {
|
|
239
|
+
return err({ kind: "io_error", path: "", message: `${key} failed` });
|
|
240
|
+
}
|
|
241
|
+
return ok(undefined);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for NodeSystemOps — local tests that don't require root.
|
|
3
|
+
*
|
|
4
|
+
* Tests stat, readFile, hashFile, chmod (on own files), userExists, groupExists.
|
|
5
|
+
* chown tests require root and live in the Docker integration suite.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
9
|
+
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { NodeSystemOps } from "./system-ops-node.js";
|
|
14
|
+
|
|
15
|
+
let workspace: string;
|
|
16
|
+
let ops: NodeSystemOps;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
workspace = await mkdtemp(join(tmpdir(), "soulguard-test-"));
|
|
20
|
+
ops = new NodeSystemOps(workspace);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await rm(workspace, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("NodeSystemOps", () => {
|
|
28
|
+
// ── stat ────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("stat", () => {
|
|
31
|
+
test("returns ownership info for existing file", async () => {
|
|
32
|
+
await writeFile(join(workspace, "test.md"), "hello");
|
|
33
|
+
|
|
34
|
+
const result = await ops.stat("test.md");
|
|
35
|
+
expect(result.ok).toBe(true);
|
|
36
|
+
if (!result.ok) return;
|
|
37
|
+
|
|
38
|
+
expect(result.value.path).toBe("test.md");
|
|
39
|
+
expect(result.value.ownership.user).toBeTruthy();
|
|
40
|
+
expect(result.value.ownership.group).toBeTruthy();
|
|
41
|
+
expect(result.value.ownership.mode).toMatch(/^\d{3}$/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns not_found for missing file", async () => {
|
|
45
|
+
const result = await ops.stat("nope.md");
|
|
46
|
+
expect(result.ok).toBe(false);
|
|
47
|
+
if (result.ok) return;
|
|
48
|
+
expect(result.error.kind).toBe("not_found");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects path traversal", async () => {
|
|
52
|
+
const result = await ops.stat("../../etc/passwd");
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
if (result.ok) return;
|
|
55
|
+
expect(result.error.kind).toBe("io_error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles nested paths", async () => {
|
|
59
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
60
|
+
await writeFile(join(workspace, "sub", "nested.md"), "deep");
|
|
61
|
+
|
|
62
|
+
const result = await ops.stat("sub/nested.md");
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
if (!result.ok) return;
|
|
65
|
+
expect(result.value.path).toBe("sub/nested.md");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── readFile ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("readFile", () => {
|
|
72
|
+
test("reads file contents", async () => {
|
|
73
|
+
await writeFile(join(workspace, "test.md"), "hello world");
|
|
74
|
+
|
|
75
|
+
const result = await ops.readFile("test.md");
|
|
76
|
+
expect(result.ok).toBe(true);
|
|
77
|
+
if (!result.ok) return;
|
|
78
|
+
expect(result.value).toBe("hello world");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns not_found for missing file", async () => {
|
|
82
|
+
const result = await ops.readFile("nope.md");
|
|
83
|
+
expect(result.ok).toBe(false);
|
|
84
|
+
if (result.ok) return;
|
|
85
|
+
expect(result.error.kind).toBe("not_found");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("rejects path traversal", async () => {
|
|
89
|
+
const result = await ops.readFile("../../etc/passwd");
|
|
90
|
+
expect(result.ok).toBe(false);
|
|
91
|
+
if (result.ok) return;
|
|
92
|
+
expect(result.error.kind).toBe("io_error");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── hashFile ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("hashFile", () => {
|
|
99
|
+
test("returns correct SHA-256 hash", async () => {
|
|
100
|
+
const content = "hello world";
|
|
101
|
+
await writeFile(join(workspace, "test.md"), content);
|
|
102
|
+
|
|
103
|
+
const expected = createHash("sha256").update(content).digest("hex");
|
|
104
|
+
|
|
105
|
+
const result = await ops.hashFile("test.md");
|
|
106
|
+
expect(result.ok).toBe(true);
|
|
107
|
+
if (!result.ok) return;
|
|
108
|
+
expect(result.value).toBe(expected);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns not_found for missing file", async () => {
|
|
112
|
+
const result = await ops.hashFile("nope.md");
|
|
113
|
+
expect(result.ok).toBe(false);
|
|
114
|
+
if (result.ok) return;
|
|
115
|
+
expect(result.error.kind).toBe("not_found");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── chmod ───────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("chmod", () => {
|
|
122
|
+
test("changes file permissions", async () => {
|
|
123
|
+
await writeFile(join(workspace, "test.md"), "hello");
|
|
124
|
+
|
|
125
|
+
const result = await ops.chmod("test.md", "444");
|
|
126
|
+
expect(result.ok).toBe(true);
|
|
127
|
+
|
|
128
|
+
const stat = await ops.stat("test.md");
|
|
129
|
+
expect(stat.ok).toBe(true);
|
|
130
|
+
if (!stat.ok) return;
|
|
131
|
+
expect(stat.value.ownership.mode).toBe("444");
|
|
132
|
+
|
|
133
|
+
// Restore so cleanup works
|
|
134
|
+
await ops.chmod("test.md", "644");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("returns not_found for missing file", async () => {
|
|
138
|
+
const result = await ops.chmod("nope.md", "444");
|
|
139
|
+
expect(result.ok).toBe(false);
|
|
140
|
+
if (result.ok) return;
|
|
141
|
+
expect(result.error.kind).toBe("not_found");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── userExists ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe("userExists", () => {
|
|
148
|
+
test("returns true for current user", async () => {
|
|
149
|
+
const currentUser = process.env.USER ?? "root";
|
|
150
|
+
const result = await ops.userExists(currentUser);
|
|
151
|
+
expect(result.ok).toBe(true);
|
|
152
|
+
if (!result.ok) return;
|
|
153
|
+
expect(result.value).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("returns false for nonexistent user", async () => {
|
|
157
|
+
const result = await ops.userExists("soulguard_nonexistent_user_xyz");
|
|
158
|
+
expect(result.ok).toBe(true);
|
|
159
|
+
if (!result.ok) return;
|
|
160
|
+
expect(result.value).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── groupExists ─────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("groupExists", () => {
|
|
167
|
+
test("returns true for staff group", async () => {
|
|
168
|
+
// 'staff' exists on macOS, 'root' on Linux
|
|
169
|
+
const group = process.platform === "darwin" ? "staff" : "root";
|
|
170
|
+
const result = await ops.groupExists(group);
|
|
171
|
+
expect(result.ok).toBe(true);
|
|
172
|
+
if (!result.ok) return;
|
|
173
|
+
expect(result.value).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("returns false for nonexistent group", async () => {
|
|
177
|
+
const result = await ops.groupExists("soulguard_nonexistent_group_xyz");
|
|
178
|
+
expect(result.ok).toBe(true);
|
|
179
|
+
if (!result.ok) return;
|
|
180
|
+
expect(result.value).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|