relembra 1.0.0-beta.1 → 1.0.0-beta.3
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 +26 -12
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +1 -0
- package/dist/cli/main.js +13 -11
- package/dist/cli/scope.d.ts +3 -1
- package/dist/cli/scope.js +52 -10
- package/dist/sqlite/database.d.ts +34 -0
- package/dist/sqlite/database.js +227 -5
- package/dist/sqlite/windows-acl.d.ts +44 -0
- package/dist/sqlite/windows-acl.js +236 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/FORMAT.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ and storage (an adapter). relembra enforces the rules:
|
|
|
25
25
|
- **Measured.** An evaluation harness with leak, scope, retrieval and wake-coverage gates, plus
|
|
26
26
|
controls that must fail. Results: [docs/eval/](docs/eval/).
|
|
27
27
|
|
|
28
|
-
Version: 1.0.0-beta.
|
|
28
|
+
Version: 1.0.0-beta.3, a beta ([changelog](CHANGELOG.md),
|
|
29
29
|
[releases](https://github.com/mneves75/relembra/releases)). Release procedure:
|
|
30
30
|
[docs/RELEASE.md](docs/RELEASE.md).
|
|
31
31
|
Design: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
@@ -203,23 +203,36 @@ scope. Outside a repository it is the scope itself. The wake gives each of the t
|
|
|
203
203
|
its budget and passes the room one leaves unused to the other, so neither can crowd the other
|
|
204
204
|
out. Set `RELEMBRA_SCOPE` to the global scope to change what it holds.
|
|
205
205
|
|
|
206
|
-
Each [GitHub release](https://github.com/mneves75/relembra/releases) carries
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
Each [GitHub release](https://github.com/mneves75/relembra/releases) carries a native binary
|
|
207
|
+
per platform, `SHA256SUMS` and the library archive npm serves:
|
|
208
|
+
|
|
209
|
+
| Platform | Binary |
|
|
210
|
+
|---|---|
|
|
211
|
+
| macOS arm64 | `relembra-darwin-arm64` |
|
|
212
|
+
| Linux x64 (glibc) | `relembra-linux-x64` |
|
|
213
|
+
| Linux arm64 (glibc) | `relembra-linux-arm64` |
|
|
214
|
+
| Windows x64 | `relembra-windows-x64.exe` |
|
|
215
|
+
| Windows arm64 | `relembra-windows-arm64.exe` |
|
|
216
|
+
|
|
217
|
+
On macOS and Linux, download the binary and checksum file, then install them with `install.sh`
|
|
218
|
+
from a checkout of the same tag:
|
|
209
219
|
|
|
210
220
|
```sh
|
|
211
|
-
sh install.sh relembra-
|
|
221
|
+
sh install.sh relembra-linux-x64 SHA256SUMS # verifies the checksum, installs to ~/.local/bin
|
|
212
222
|
```
|
|
213
223
|
|
|
224
|
+
On Windows, check the hash with `Get-FileHash` and copy the binary onto `PATH` as
|
|
225
|
+
`relembra.exe`; the session hook needs Git Bash ([docs/CLI.md](docs/CLI.md#windows)).
|
|
226
|
+
|
|
214
227
|
Before replacing an installed executable, retain a backup and its hash. Verify installed bytes
|
|
215
228
|
against the release checksum and export metadata from a disposable store
|
|
216
|
-
(`relembra: "1.0.0-beta.
|
|
229
|
+
(`relembra: "1.0.0-beta.3"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
|
|
217
230
|
rollback without opening a live memory store. After verification, `relembra init` prints the
|
|
218
231
|
session hook for your agent.
|
|
219
232
|
|
|
220
|
-
|
|
221
|
-
(writes `bin/relembra`
|
|
222
|
-
|
|
233
|
+
To build it yourself, run `bun run build:cli` from a clone with the pinned Bun
|
|
234
|
+
(`build/bun-toolchain.txt`); it writes `bin/relembra` (`relembra.exe` on Windows) and
|
|
235
|
+
`bin/relembra.sha256`.
|
|
223
236
|
|
|
224
237
|
Commands, flags, exit codes and the JSON envelope: [docs/CLI.md](docs/CLI.md).
|
|
225
238
|
|
|
@@ -304,9 +317,10 @@ The landing page is `site/`: static files, no build step besides `bun run build:
|
|
|
304
317
|
the library into `site/assets/relembra.js` so the page's demos run the real code in the browser
|
|
305
318
|
(`test/site.test.ts` fails while that file is stale). Preview it with
|
|
306
319
|
`python3 -m http.server --directory site`, or open `site/index.html` directly.
|
|
307
|
-
`
|
|
308
|
-
|
|
309
|
-
git-ignored on `main` and
|
|
320
|
+
The `pages` workflow (`gh workflow run pages.yml --ref main`) publishes it to GitHub Pages with
|
|
321
|
+
OIDC, no stored token: `scripts/site/assemble.sh <new directory>` builds what ships from the
|
|
322
|
+
commit's `site/` plus the explainer video, which is git-ignored on `main` and kept on the
|
|
323
|
+
`gh-pages` branch, checked against `scripts/site/video.sha256`.
|
|
310
324
|
|
|
311
325
|
Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md).
|
|
312
326
|
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -36,5 +36,5 @@ export declare const COMMANDS: readonly CommandSpec[];
|
|
|
36
36
|
/** Flags every command takes. */
|
|
37
37
|
export declare const GLOBAL_FLAGS: readonly ["--json", "--help"];
|
|
38
38
|
/** The environment the CLI reads, in the order docs/CLI.md lists it. */
|
|
39
|
-
export declare const ENV_VARS: readonly ["RELEMBRA_HOME", "RELEMBRA_SCOPE", "RELEMBRA_GLOBAL_SCOPE", "RELEMBRA_AGENT", "RELEMBRA_HARNESS", "RELEMBRA_MODEL", "BUN_OPTIONS", "BUN_BYTECODE_ORDER_OUT", "PATH", "HOME", "XDG_CONFIG_HOME"];
|
|
39
|
+
export declare const ENV_VARS: readonly ["RELEMBRA_HOME", "RELEMBRA_SCOPE", "RELEMBRA_GLOBAL_SCOPE", "RELEMBRA_AGENT", "RELEMBRA_HARNESS", "RELEMBRA_MODEL", "BUN_OPTIONS", "BUN_BYTECODE_ORDER_OUT", "PATH", "HOME", "XDG_CONFIG_HOME", "SystemRoot"];
|
|
40
40
|
export declare function usageText(): string;
|
package/dist/cli/commands.js
CHANGED
package/dist/cli/main.js
CHANGED
|
@@ -21,6 +21,7 @@ import { LATEST_EVENT_FORMAT, } from "../model.js";
|
|
|
21
21
|
import { importOptmem } from "../optmem.js";
|
|
22
22
|
import { createRelembra } from "../relembra.js";
|
|
23
23
|
import { identityEgress, renderMemory } from "../render.js";
|
|
24
|
+
import { OWNER_MODES } from "../sqlite/database.js";
|
|
24
25
|
import { openSqliteStore } from "../sqlite/index.js";
|
|
25
26
|
import { exportJsonl, importJsonl, MAX_IMPORT_BYTES } from "../transfer.js";
|
|
26
27
|
import { flagValue, hasFlag, intFlag, parseArgs, wantsJson } from "./args.js";
|
|
@@ -38,10 +39,10 @@ const MAX_BUDGET = 1_000_000;
|
|
|
38
39
|
const MAX_AGE_DAYS = 36_500;
|
|
39
40
|
const MAX_SOURCES = 100;
|
|
40
41
|
/** Reads the session's scope and the global one; writes, pins and owns the session's only. */
|
|
41
|
-
function principalFor(scopes, trustCeiling) {
|
|
42
|
+
function principalFor(env, scopes, trustCeiling) {
|
|
42
43
|
const scope = scopes.session;
|
|
43
44
|
return {
|
|
44
|
-
id: osUser(),
|
|
45
|
+
id: osUser(env),
|
|
45
46
|
readable: scopes.global === undefined ? [scope] : [scope, scopes.global],
|
|
46
47
|
writable: [scope],
|
|
47
48
|
promotable: [],
|
|
@@ -57,8 +58,8 @@ function principalFor(scopes, trustCeiling) {
|
|
|
57
58
|
* (`denied:above-ceiling`), as it would be without the owner's confirmation. Export needs
|
|
58
59
|
* ownership only, not trust.
|
|
59
60
|
*/
|
|
60
|
-
function transferPrincipal(scope) {
|
|
61
|
-
return principalFor({ session: scope }, "agent");
|
|
61
|
+
function transferPrincipal(env, scope) {
|
|
62
|
+
return principalFor(env, { session: scope }, "agent");
|
|
62
63
|
}
|
|
63
64
|
function provenanceFrom(context) {
|
|
64
65
|
const { parsed, env } = context;
|
|
@@ -189,7 +190,7 @@ function hookCommand() {
|
|
|
189
190
|
* with harness `mcp` by default. Log lines go to stderr; stdout carries protocol messages only.
|
|
190
191
|
*/
|
|
191
192
|
async function serveStdio(env, scopes, store) {
|
|
192
|
-
const principal = principalFor(scopes, "agent");
|
|
193
|
+
const principal = principalFor(env, scopes, "agent");
|
|
193
194
|
const egress = identityEgress;
|
|
194
195
|
const relembra = createRelembra({ store, policy: () => principal, egress });
|
|
195
196
|
const pick = (variable, fallback) => variable === undefined || variable === "" ? fallback : variable;
|
|
@@ -249,7 +250,7 @@ async function runStoreCommand(context, scopes, store, path) {
|
|
|
249
250
|
});
|
|
250
251
|
const relembra = createRelembra({
|
|
251
252
|
store,
|
|
252
|
-
policy: () => principalFor(scopes, ceiling),
|
|
253
|
+
policy: () => principalFor(context.env, scopes, ceiling),
|
|
253
254
|
...(maxAgeDays === undefined ? {} : { retention: [{ scope, maxAgeDays }] }),
|
|
254
255
|
});
|
|
255
256
|
switch (name) {
|
|
@@ -399,7 +400,7 @@ async function runStoreCommand(context, scopes, store, path) {
|
|
|
399
400
|
case "check":
|
|
400
401
|
return check(scope, store, path);
|
|
401
402
|
case "export": {
|
|
402
|
-
const jsonl = await exportJsonl(store, transferPrincipal(scope), { scope });
|
|
403
|
+
const jsonl = await exportJsonl(store, transferPrincipal(context.env, scope), { scope });
|
|
403
404
|
const [header, ...events] = jsonl
|
|
404
405
|
.split("\n")
|
|
405
406
|
.slice(0, -1)
|
|
@@ -412,7 +413,7 @@ async function runStoreCommand(context, scopes, store, path) {
|
|
|
412
413
|
throw usage("missing-argument", "import takes a file, or --optmem <dir>, not both");
|
|
413
414
|
}
|
|
414
415
|
const report = optmem === undefined
|
|
415
|
-
? await importJsonl(store, transferPrincipal(scope), readImportFile(context, first))
|
|
416
|
+
? await importJsonl(store, transferPrincipal(context.env, scope), readImportFile(context, first))
|
|
416
417
|
: await importFromOptmem(context, scope, store, optmem);
|
|
417
418
|
return { data: report, text: reportText(report) };
|
|
418
419
|
}
|
|
@@ -453,7 +454,7 @@ async function importFromOptmem(context, scope, store, dir) {
|
|
|
453
454
|
}
|
|
454
455
|
const log = readRegularFile(join(real, "LOG.txt"), "the OptMem log");
|
|
455
456
|
const { agent, harness, model, host } = provenanceFrom(context);
|
|
456
|
-
return importOptmem(store, transferPrincipal(scope), {
|
|
457
|
+
return importOptmem(store, transferPrincipal(context.env, scope), {
|
|
457
458
|
scope,
|
|
458
459
|
store: createHash("sha256").update(real).digest("hex").slice(0, 16),
|
|
459
460
|
log,
|
|
@@ -540,7 +541,7 @@ function parseSource(value) {
|
|
|
540
541
|
*/
|
|
541
542
|
async function check(scope, store, path) {
|
|
542
543
|
const homeMode = Number(lstatSync(dirname(path)).mode) & 0o777;
|
|
543
|
-
if ((homeMode & 0o077) !== 0) {
|
|
544
|
+
if (OWNER_MODES && (homeMode & 0o077) !== 0) {
|
|
544
545
|
throw new CliError("store-file", "the store's directory grants group or others access", "mode");
|
|
545
546
|
}
|
|
546
547
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
@@ -747,7 +748,7 @@ function fail(error, json, write) {
|
|
|
747
748
|
}
|
|
748
749
|
if (import.meta.main) {
|
|
749
750
|
// Only the variables docs/CLI.md lists are read.
|
|
750
|
-
const { RELEMBRA_HOME, RELEMBRA_SCOPE, RELEMBRA_GLOBAL_SCOPE, RELEMBRA_AGENT, RELEMBRA_HARNESS, RELEMBRA_MODEL, BUN_OPTIONS, BUN_BYTECODE_ORDER_OUT, PATH, HOME, XDG_CONFIG_HOME, } = process.env;
|
|
751
|
+
const { RELEMBRA_HOME, RELEMBRA_SCOPE, RELEMBRA_GLOBAL_SCOPE, RELEMBRA_AGENT, RELEMBRA_HARNESS, RELEMBRA_MODEL, BUN_OPTIONS, BUN_BYTECODE_ORDER_OUT, PATH, HOME, XDG_CONFIG_HOME, SystemRoot, } = process.env;
|
|
751
752
|
const env = {
|
|
752
753
|
RELEMBRA_HOME,
|
|
753
754
|
RELEMBRA_SCOPE,
|
|
@@ -760,6 +761,7 @@ if (import.meta.main) {
|
|
|
760
761
|
PATH,
|
|
761
762
|
HOME,
|
|
762
763
|
XDG_CONFIG_HOME,
|
|
764
|
+
SystemRoot,
|
|
763
765
|
};
|
|
764
766
|
const code = await main(process.argv.slice(2), env, process.cwd(), process.execArgv);
|
|
765
767
|
process.exitCode = code;
|
package/dist/cli/scope.d.ts
CHANGED
|
@@ -14,13 +14,15 @@ export interface CliEnv {
|
|
|
14
14
|
readonly PATH?: string | undefined;
|
|
15
15
|
readonly HOME?: string | undefined;
|
|
16
16
|
readonly XDG_CONFIG_HOME?: string | undefined;
|
|
17
|
+
/** Windows only: the Windows folder, whose System32 holds whoami.exe; default `C:\Windows`. */
|
|
18
|
+
readonly SystemRoot?: string | undefined;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* The OS user, as `uid-<uid>`. Not the user name: Bun's os.userInfo().username reads $USER and
|
|
20
22
|
* answers "unknown" when it is unset (measured on Bun 1.4.4; Node asks getpwuid), so a hook run
|
|
21
23
|
* without USER would land in a second scope. The uid is the same in every environment.
|
|
22
24
|
*/
|
|
23
|
-
export declare function osUser(): string;
|
|
25
|
+
export declare function osUser(env: CliEnv): string;
|
|
24
26
|
/** The store file: RELEMBRA_HOME/relembra.db, RELEMBRA_HOME defaulting to ~/.relembra. */
|
|
25
27
|
export declare function storePath(env: CliEnv, cwd: string): string;
|
|
26
28
|
/** The global scope RELEMBRA_GLOBAL_SCOPE names, checked like RELEMBRA_SCOPE; undefined unset. */
|
package/dist/cli/scope.js
CHANGED
|
@@ -2,21 +2,41 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
6
|
import { findGuardViolation } from "../guard.js";
|
|
7
7
|
import { MAX_SCOPE_BYTES, utf8Bytes } from "../render.js";
|
|
8
|
+
import { windowsUserSid } from "../sqlite/database.js";
|
|
8
9
|
import { CliError } from "./errors.js";
|
|
9
10
|
/**
|
|
10
11
|
* The OS user, as `uid-<uid>`. Not the user name: Bun's os.userInfo().username reads $USER and
|
|
11
12
|
* answers "unknown" when it is unset (measured on Bun 1.4.4; Node asks getpwuid), so a hook run
|
|
12
13
|
* without USER would land in a second scope. The uid is the same in every environment.
|
|
13
14
|
*/
|
|
14
|
-
export function osUser() {
|
|
15
|
+
export function osUser(env) {
|
|
16
|
+
if (process.platform === "win32")
|
|
17
|
+
return windowsUser(env.SystemRoot);
|
|
15
18
|
const uid = process.getuid?.();
|
|
16
19
|
if (uid === undefined)
|
|
17
20
|
throw new CliError("no-scope", "this platform has no OS user id");
|
|
18
21
|
return `uid-${uid}`;
|
|
19
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Windows has no uid: the user is `sid-<SID>`, the logon account's security identifier, which,
|
|
25
|
+
* like a uid, is one per account (`PC\alex` and `CORP\alex` differ) and survives a rename. It
|
|
26
|
+
* comes from the process token through whoami, never from os.userInfo(): Bun reads that from
|
|
27
|
+
* %USERNAME% on Windows (seen on the hosted runners), which the hook's `env -i` clears and any
|
|
28
|
+
* caller can set. An account whoami cannot name is refused, never mapped to a shared scope.
|
|
29
|
+
* windowsUserSid runs whoami once per process, though the policy builds a principal for every
|
|
30
|
+
* operation.
|
|
31
|
+
*/
|
|
32
|
+
function windowsUser(systemRoot) {
|
|
33
|
+
try {
|
|
34
|
+
return `sid-${windowsUserSid(systemRoot)}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new CliError("no-scope", "no scope: the OS did not name the user; set RELEMBRA_SCOPE");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
20
40
|
/** The store file: RELEMBRA_HOME/relembra.db, RELEMBRA_HOME defaulting to ~/.relembra. */
|
|
21
41
|
export function storePath(env, cwd) {
|
|
22
42
|
const home = env.RELEMBRA_HOME;
|
|
@@ -46,7 +66,7 @@ export function resolveScope(env, cwd) {
|
|
|
46
66
|
if (found === undefined)
|
|
47
67
|
return global;
|
|
48
68
|
const hash = createHash("sha256").update(found.key).digest("hex").slice(0, 16);
|
|
49
|
-
return checkScope(`${osUser()}/${found.name}-${hash}`);
|
|
69
|
+
return checkScope(`${osUser(env)}/${found.name}-${hash}`);
|
|
50
70
|
}
|
|
51
71
|
/**
|
|
52
72
|
* The scopes the session reads (src/cli/wake.ts): its own (resolveScope) and the global scope
|
|
@@ -59,6 +79,9 @@ export function resolveScopes(env, cwd) {
|
|
|
59
79
|
const global = resolveGlobalScope(env);
|
|
60
80
|
return global === undefined || global === session ? { session } : { session, global };
|
|
61
81
|
}
|
|
82
|
+
const WINDOWS = process.platform === "win32";
|
|
83
|
+
/** Git's file name: Windows runs only files named .exe. */
|
|
84
|
+
const GIT = WINDOWS ? "git.exe" : "git";
|
|
62
85
|
/** Git gets this long to answer each question before the scope is refused. */
|
|
63
86
|
const GIT_TIMEOUT_MS = 2_000;
|
|
64
87
|
/** Characters a path or URL from git may not hold: control characters, a newline among them. */
|
|
@@ -82,18 +105,20 @@ function hostGit(env, cwd) {
|
|
|
82
105
|
if (dirname(dir) === dir)
|
|
83
106
|
break;
|
|
84
107
|
}
|
|
85
|
-
|
|
108
|
+
// Windows paths ignore case, so a fence compared by bytes would let C:\REPO\bin past C:\repo.
|
|
109
|
+
const fold = (path) => (WINDOWS ? path.toLowerCase() : path);
|
|
110
|
+
const inside = (dir) => fences.some((f) => fold(dir) === fold(f) || fold(dir).startsWith(fold(`${f}${sep}`)));
|
|
86
111
|
const entries = (env.PATH ?? "")
|
|
87
|
-
.split(
|
|
112
|
+
.split(delimiter)
|
|
88
113
|
.filter((entry) => isAbsolute(entry) && !inside(safeRealpath(entry)));
|
|
89
114
|
for (const entry of entries) {
|
|
90
115
|
try {
|
|
91
|
-
const candidate =
|
|
116
|
+
const candidate = realpath(join(entry, GIT));
|
|
92
117
|
if (inside(candidate))
|
|
93
118
|
continue;
|
|
94
119
|
if (statSync(candidate).isFile()) {
|
|
95
120
|
accessSync(candidate, constants.X_OK);
|
|
96
|
-
return { git: candidate, path: entries.join(
|
|
121
|
+
return { git: candidate, path: entries.join(delimiter) };
|
|
97
122
|
}
|
|
98
123
|
}
|
|
99
124
|
catch {
|
|
@@ -217,6 +242,10 @@ export function normalizeRemote(url, base) {
|
|
|
217
242
|
if (port === DEFAULT_PORTS[name])
|
|
218
243
|
port = "";
|
|
219
244
|
}
|
|
245
|
+
else if (WINDOWS && /^[A-Za-z]:[\\/]/.test(rest)) {
|
|
246
|
+
// A drive path, which git on Windows reads as local, never as an scp-like host "C".
|
|
247
|
+
return localKey(rest, base);
|
|
248
|
+
}
|
|
220
249
|
else {
|
|
221
250
|
// scp-like `[user@]host:path`, as git reads it: a colon before any slash.
|
|
222
251
|
const scp = /^(?:[^@/]+@)?(\[[^\]]*\]|[^/:]+):(.*)$/.exec(rest);
|
|
@@ -259,15 +288,28 @@ function filePath(afterScheme) {
|
|
|
259
288
|
if (UNSUPPORTED.test(path)) {
|
|
260
289
|
throw new CliError("no-scope", "no scope: the origin's file URL decodes to a control character; set RELEMBRA_SCOPE", "unsupported-path");
|
|
261
290
|
}
|
|
262
|
-
|
|
291
|
+
// On Windows a drive path is written /C:/x in the URL; the path is C:/x.
|
|
292
|
+
return WINDOWS && /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
263
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The key of a local origin: `file:` and its real path, with forward slashes on Windows too, so
|
|
296
|
+
* the project name beside the hash is the last folder, never the whole path.
|
|
297
|
+
*/
|
|
264
298
|
function localKey(path, base) {
|
|
265
299
|
const absolute = isAbsolute(path) ? path : resolve(base(), path);
|
|
266
|
-
|
|
300
|
+
const real = safeRealpath(absolute);
|
|
301
|
+
return `file:${(WINDOWS ? real.replaceAll("\\", "/") : real).replace(/(?<=.)\/+$/, "")}`;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The real path. On Windows the native call, which also gives the canonical case and long names,
|
|
305
|
+
* so one folder reached as C:\Repo and c:\repo is one project and one fence.
|
|
306
|
+
*/
|
|
307
|
+
function realpath(path) {
|
|
308
|
+
return WINDOWS ? realpathSync.native(path) : realpathSync(path);
|
|
267
309
|
}
|
|
268
310
|
function safeRealpath(path) {
|
|
269
311
|
try {
|
|
270
|
-
return
|
|
312
|
+
return realpath(path);
|
|
271
313
|
}
|
|
272
314
|
catch {
|
|
273
315
|
return resolve(path);
|
|
@@ -30,12 +30,46 @@ export declare class StoreUnverifiedError extends Error {
|
|
|
30
30
|
export declare function isBusy(error: unknown): boolean;
|
|
31
31
|
/** Refuses a busy timeout that would make waits unbounded or meaningless. */
|
|
32
32
|
export declare function checkBusyTimeout(busyTimeoutMs: number): number;
|
|
33
|
+
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
34
|
+
export declare const OWNER_MODES: boolean;
|
|
33
35
|
/**
|
|
34
36
|
* Creates missing directories as 0700 and a missing file as 0600, then checks the files with
|
|
35
37
|
* checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
|
|
36
38
|
* appears behind it.
|
|
37
39
|
*/
|
|
38
40
|
export declare function createOwnerOnly(path: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Windows' System32 folder under `systemRoot` (%SystemRoot%). An empty or relative value falls
|
|
43
|
+
* back to `C:\Windows`: a relative executable path would be resolved against the working
|
|
44
|
+
* directory, which may be a repository's.
|
|
45
|
+
*/
|
|
46
|
+
export declare function windowsSystem32(systemRoot: string | undefined): string;
|
|
47
|
+
/**
|
|
48
|
+
* The logon account's SID, from the process token as whoami prints it. Thrown when none. The
|
|
49
|
+
* account cannot change while the process runs, so whoami runs once: the CLI's scope and a new
|
|
50
|
+
* store's ACL both ask.
|
|
51
|
+
*/
|
|
52
|
+
export declare function windowsUserSid(systemRoot: string | undefined): string;
|
|
53
|
+
/**
|
|
54
|
+
* Windows: makes the current user the folder's owner, then replaces its ACL with full control for
|
|
55
|
+
* the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
|
|
56
|
+
* always rewrite an ACL, so a folder another account made would otherwise give that account its
|
|
57
|
+
* access back; `/setowner` fails, and no store is created, unless the user may take ownership.
|
|
58
|
+
* `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
|
|
59
|
+
* that never widens access: another process may already have made the store here (two first
|
|
60
|
+
* opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
|
|
61
|
+
* store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
|
|
62
|
+
* ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
|
|
63
|
+
* Accounts are named by SID, so no account name or system language is involved. Any failure is
|
|
64
|
+
* thrown: a store is never created in a folder others may read.
|
|
65
|
+
*/
|
|
66
|
+
export declare function restrictFolderToOwner(dir: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
69
|
+
* A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
|
|
70
|
+
* three would let a foreign grant pass as the third.
|
|
71
|
+
*/
|
|
72
|
+
export declare function storeFolderAccounts(userSid: string): string[];
|
|
39
73
|
/**
|
|
40
74
|
* The store file must exist. It and any -wal or -shm file must be regular files (lstat never
|
|
41
75
|
* follows a symbolic link) that grant group and others nothing. A loose file is refused, never
|
package/dist/sqlite/database.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, win32 } from "node:path";
|
|
3
6
|
import { DatabaseSync } from "node:sqlite";
|
|
4
7
|
import { pathToFileURL } from "node:url";
|
|
5
8
|
import { StoreBusyError } from "../errors.js";
|
|
6
9
|
import { MIGRATIONS, migrate, SchemaTooNewError, schemaVersion } from "./migrations.js";
|
|
7
10
|
export { StoreBusyError };
|
|
8
11
|
import { walImage } from "./wal-image.js";
|
|
12
|
+
import { chainRefusal, storeFolderChain, trustedAccounts, } from "./windows-acl.js";
|
|
9
13
|
/** Thrown when an existing store file, or its -wal or -shm file, grants group or others access. */
|
|
10
14
|
export class StoreFileModeError extends Error {
|
|
11
15
|
name = "StoreFileModeError";
|
|
@@ -52,6 +56,8 @@ export function checkBusyTimeout(busyTimeoutMs) {
|
|
|
52
56
|
}
|
|
53
57
|
return busyTimeoutMs;
|
|
54
58
|
}
|
|
59
|
+
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
60
|
+
export const OWNER_MODES = process.platform !== "win32";
|
|
55
61
|
/**
|
|
56
62
|
* Creates missing directories as 0700 and a missing file as 0600, then checks the files with
|
|
57
63
|
* checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
|
|
@@ -61,6 +67,21 @@ export function createOwnerOnly(path) {
|
|
|
61
67
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
62
68
|
if (isSymbolicLink(path))
|
|
63
69
|
throw new StoreFileTypeError(path);
|
|
70
|
+
// Windows ignores the modes: before a new store's first byte, its folder gets an ACL of its own,
|
|
71
|
+
// so the store and the -wal and -shm files SQLite makes beside it inherit owner-only access.
|
|
72
|
+
if (!OWNER_MODES && !existsSync(path)) {
|
|
73
|
+
restrictFolderToOwner(dirname(path));
|
|
74
|
+
// A sidecar without its store was put there by someone: it keeps its own ACL, and Windows
|
|
75
|
+
// opens a file by its full path whatever the folder grants, so SQLite would write into a file
|
|
76
|
+
// another account can read. Checked after the ACL, when no other account can add one. A
|
|
77
|
+
// sidecar beside a store that exists by now belongs to another process that created the
|
|
78
|
+
// store first (its folder got the same three grants), so it is no plant.
|
|
79
|
+
for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) {
|
|
80
|
+
if (hasEntry(sidecar) && !hasEntry(path)) {
|
|
81
|
+
throw new Error(`${sidecar} exists without its store; no store is created beside it`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
64
85
|
try {
|
|
65
86
|
// O_EXCL never follows a link, so a link made since the check fails here with EEXIST.
|
|
66
87
|
closeSync(openSync(path, "wx", 0o600));
|
|
@@ -71,6 +92,185 @@ export function createOwnerOnly(path) {
|
|
|
71
92
|
}
|
|
72
93
|
checkOwnerOnly(path);
|
|
73
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Windows' System32 folder under `systemRoot` (%SystemRoot%). An empty or relative value falls
|
|
97
|
+
* back to `C:\Windows`: a relative executable path would be resolved against the working
|
|
98
|
+
* directory, which may be a repository's.
|
|
99
|
+
*/
|
|
100
|
+
export function windowsSystem32(systemRoot) {
|
|
101
|
+
const root = systemRoot !== undefined && win32.isAbsolute(systemRoot) && /^[A-Za-z]:/.test(systemRoot)
|
|
102
|
+
? systemRoot
|
|
103
|
+
: "C:\\Windows";
|
|
104
|
+
return win32.join(root, "System32");
|
|
105
|
+
}
|
|
106
|
+
/** Runs a System32 tool; any failure is thrown with its output. */
|
|
107
|
+
function runSystemTool(systemRoot, exe, args) {
|
|
108
|
+
const result = spawnSync(win32.join(windowsSystem32(systemRoot), exe), args, {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
shell: false,
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
timeout: 10_000,
|
|
113
|
+
});
|
|
114
|
+
if (result.status !== 0) {
|
|
115
|
+
throw new Error(`${exe} failed (exit ${result.status}): ${result.stderr || result.stdout}`);
|
|
116
|
+
}
|
|
117
|
+
return result.stdout;
|
|
118
|
+
}
|
|
119
|
+
let userSid;
|
|
120
|
+
/**
|
|
121
|
+
* The logon account's SID, from the process token as whoami prints it. Thrown when none. The
|
|
122
|
+
* account cannot change while the process runs, so whoami runs once: the CLI's scope and a new
|
|
123
|
+
* store's ACL both ask.
|
|
124
|
+
*/
|
|
125
|
+
export function windowsUserSid(systemRoot) {
|
|
126
|
+
if (userSid !== undefined)
|
|
127
|
+
return userSid;
|
|
128
|
+
// `"HOST\user","S-1-5-21-..."`: the SID is the last quoted field.
|
|
129
|
+
const printed = runSystemTool(systemRoot, "whoami.exe", ["/user", "/fo", "csv", "/nh"]);
|
|
130
|
+
const sid = /"(S-1-[0-9-]+)"\s*$/.exec(printed)?.[1];
|
|
131
|
+
if (sid === undefined)
|
|
132
|
+
throw new Error("whoami did not print the user's SID");
|
|
133
|
+
userSid = sid;
|
|
134
|
+
return sid;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Windows: makes the current user the folder's owner, then replaces its ACL with full control for
|
|
138
|
+
* the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
|
|
139
|
+
* always rewrite an ACL, so a folder another account made would otherwise give that account its
|
|
140
|
+
* access back; `/setowner` fails, and no store is created, unless the user may take ownership.
|
|
141
|
+
* `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
|
|
142
|
+
* that never widens access: another process may already have made the store here (two first
|
|
143
|
+
* opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
|
|
144
|
+
* store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
|
|
145
|
+
* ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
|
|
146
|
+
* Accounts are named by SID, so no account name or system language is involved. Any failure is
|
|
147
|
+
* thrown: a store is never created in a folder others may read.
|
|
148
|
+
*/
|
|
149
|
+
export function restrictFolderToOwner(dir) {
|
|
150
|
+
// icacls follows a junction or symbolic link: the owner and ACL of its target would change.
|
|
151
|
+
if (lstatSync(dir).isSymbolicLink())
|
|
152
|
+
throw new Error(`${dir} is a link; no store is created in it`);
|
|
153
|
+
const systemRoot = process.env["SystemRoot"];
|
|
154
|
+
const sid = windowsUserSid(systemRoot);
|
|
155
|
+
checkFolderChain(systemRoot, dir, sid);
|
|
156
|
+
const accounts = storeFolderAccounts(sid);
|
|
157
|
+
runSystemTool(systemRoot, "icacls.exe", [dir, "/setowner", `*${sid}`]);
|
|
158
|
+
runSystemTool(systemRoot, "icacls.exe", [
|
|
159
|
+
dir,
|
|
160
|
+
"/inheritance:r",
|
|
161
|
+
...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
|
|
162
|
+
]);
|
|
163
|
+
// icacls prints one `name:(flags)` entry per line; the path, on the first, holds no `:(`.
|
|
164
|
+
const entries = runSystemTool(systemRoot, "icacls.exe", [dir])
|
|
165
|
+
.split(/\r?\n/)
|
|
166
|
+
.filter((line) => line.includes(":("));
|
|
167
|
+
if (entries.length !== accounts.length) {
|
|
168
|
+
throw new Error(`${dir} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created in it: remove those grants or use another folder`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Windows: refuses a store folder whose path another account could change (docs/specs/w-nv1/spec.md).
|
|
173
|
+
* The ACL steps work on the folder's path, so they hold only while no other account can rename,
|
|
174
|
+
* delete or re-permission a folder above it, make the store folder first, or open it before its
|
|
175
|
+
* ACL is set. Every folder from the store folder's parent up to the drive root, on the path as
|
|
176
|
+
* given (storeFolderChain), is read as SDDL with `icacls /save`, which names accounts by SID, and
|
|
177
|
+
* judged by chainRefusal. A link on the path is refused: icacls would read its target, and whoever
|
|
178
|
+
* may change the link may point it elsewhere. Owners are not read, since no stock tool prints one
|
|
179
|
+
* as a SID: an ancestor another account owns, whose access list does not name that account, is
|
|
180
|
+
* not detected.
|
|
181
|
+
*/
|
|
182
|
+
function checkFolderChain(systemRoot, dir, sid) {
|
|
183
|
+
const folders = storeFolderChain(dir);
|
|
184
|
+
for (const folder of folders) {
|
|
185
|
+
if (lstatSync(folder).isSymbolicLink()) {
|
|
186
|
+
throw new Error(`${folder} is a link; no store is created under it: use a path without links`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (folders.length === 0)
|
|
190
|
+
return;
|
|
191
|
+
const chain = [];
|
|
192
|
+
const scratch = privateScratch(systemRoot, sid);
|
|
193
|
+
try {
|
|
194
|
+
for (const [index, folder] of folders.entries()) {
|
|
195
|
+
chain.push({
|
|
196
|
+
path: folder,
|
|
197
|
+
dacl: savedDacl(systemRoot, folder, scratch),
|
|
198
|
+
holdsStoreFolder: index === 0,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
const refusal = chainRefusal(chain, trustedAccounts(sid));
|
|
206
|
+
if (refusal !== undefined) {
|
|
207
|
+
throw new Error(`${refusal}; no store is created under it: remove that grant or keep the store in a folder only you can change, such as your profile`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* A folder under TEMP for the `icacls /save` files the chain check trusts, with the store
|
|
212
|
+
* folder's own ACL: owned by the user, and full control for the user, SYSTEM and Administrators
|
|
213
|
+
* only, read back on stdout. Bun's mkdtemp already gives it those three entries, explicit; Node's
|
|
214
|
+
* gives it TEMP's inherited ACL until these steps. Each file gets a random name, so no account can
|
|
215
|
+
* make one first. The steps act on the folder's path after it exists, so TEMP is trusted for what
|
|
216
|
+
* they cannot see (docs/specs/w-nv1/spec.md): it must give other accounts no inherited access to
|
|
217
|
+
* folders made in it (a handle opened before these steps keeps its access, and could turn the
|
|
218
|
+
* folder into a junction), must not let them delete or rename its entries, and must not be theirs.
|
|
219
|
+
* The default, under the user's profile, meets that.
|
|
220
|
+
*/
|
|
221
|
+
function privateScratch(systemRoot, sid) {
|
|
222
|
+
const scratch = mkdtempSync(join(tmpdir(), "relembra-acl-"));
|
|
223
|
+
try {
|
|
224
|
+
const accounts = storeFolderAccounts(sid);
|
|
225
|
+
runSystemTool(systemRoot, "icacls.exe", [scratch, "/setowner", `*${sid}`]);
|
|
226
|
+
runSystemTool(systemRoot, "icacls.exe", [
|
|
227
|
+
scratch,
|
|
228
|
+
"/inheritance:r",
|
|
229
|
+
...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
|
|
230
|
+
]);
|
|
231
|
+
const entries = runSystemTool(systemRoot, "icacls.exe", [scratch])
|
|
232
|
+
.split(/\r?\n/)
|
|
233
|
+
.filter((line) => line.includes(":("));
|
|
234
|
+
if (entries.length !== accounts.length) {
|
|
235
|
+
throw new Error(`${scratch} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created: point TEMP at a folder only you can change`);
|
|
236
|
+
}
|
|
237
|
+
return scratch;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* A folder's access list as SDDL. `icacls <folder> /save <file>` writes the folder's name on one
|
|
246
|
+
* line (none for a drive root) and its access list on the next, in UTF-16 or the console's
|
|
247
|
+
* encoding; parseDacl drops the SACL that follows it on that line.
|
|
248
|
+
*/
|
|
249
|
+
function savedDacl(systemRoot, folder, scratch) {
|
|
250
|
+
// A fresh random name for each save, so no account can make the file first.
|
|
251
|
+
const file = join(scratch, randomBytes(16).toString("hex"));
|
|
252
|
+
runSystemTool(systemRoot, "icacls.exe", [folder, "/save", file]);
|
|
253
|
+
const bytes = readFileSync(file);
|
|
254
|
+
const utf16 = (bytes[0] === 0xff && bytes[1] === 0xfe) || bytes[1] === 0;
|
|
255
|
+
const lines = (utf16 ? bytes.toString("utf16le") : bytes.toString("utf8"))
|
|
256
|
+
.replace(/^\uFEFF/, "")
|
|
257
|
+
.split(/\r?\n/)
|
|
258
|
+
.filter((line) => line.trim() !== "");
|
|
259
|
+
// A drive root has no name, so its file holds the access list alone.
|
|
260
|
+
const dacl = lines.at(-1);
|
|
261
|
+
if (lines.length > 2 || dacl === undefined || !dacl.startsWith("D:")) {
|
|
262
|
+
throw new Error(`icacls /save did not write one access list for ${folder}: ${lines.join(" | ")}`);
|
|
263
|
+
}
|
|
264
|
+
return dacl.trim();
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
268
|
+
* A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
|
|
269
|
+
* three would let a foreign grant pass as the third.
|
|
270
|
+
*/
|
|
271
|
+
export function storeFolderAccounts(userSid) {
|
|
272
|
+
return [...new Set([userSid, "S-1-5-18", "S-1-5-32-544"])];
|
|
273
|
+
}
|
|
74
274
|
/**
|
|
75
275
|
* The store file must exist. It and any -wal or -shm file must be regular files (lstat never
|
|
76
276
|
* follows a symbolic link) that grant group and others nothing. A loose file is refused, never
|
|
@@ -90,9 +290,14 @@ export function checkOwnerOnly(path) {
|
|
|
90
290
|
}
|
|
91
291
|
if (!stats.isFile())
|
|
92
292
|
throw new StoreFileTypeError(file);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
293
|
+
// Windows has no mode bits: Node and Bun report 0o666 for every writable file, whatever its
|
|
294
|
+
// access control list grants. There the store relies on its folder's ACL, which under the
|
|
295
|
+
// user profile grants only the user, SYSTEM and Administrators (docs/CLI.md, Windows).
|
|
296
|
+
if (OWNER_MODES) {
|
|
297
|
+
const mode = Number(stats.mode) & 0o777;
|
|
298
|
+
if ((mode & 0o077) !== 0)
|
|
299
|
+
throw new StoreFileModeError(file, mode);
|
|
300
|
+
}
|
|
96
301
|
}
|
|
97
302
|
}
|
|
98
303
|
function isSymbolicLink(path) {
|
|
@@ -105,6 +310,18 @@ function isSymbolicLink(path) {
|
|
|
105
310
|
throw error;
|
|
106
311
|
}
|
|
107
312
|
}
|
|
313
|
+
/** Whether a directory entry exists at `path`: a link counts, even one whose target is missing. */
|
|
314
|
+
function hasEntry(path) {
|
|
315
|
+
try {
|
|
316
|
+
lstatSync(path);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error.code === "ENOENT")
|
|
321
|
+
return false;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
108
325
|
/**
|
|
109
326
|
* True for SQLITE_CANTOPEN (14) and SQLITE_READONLY (8), with their extended codes: what a
|
|
110
327
|
* read-only connection answers when only a write-capable one could proceed, for example
|
|
@@ -150,6 +367,11 @@ function isTorn(error) {
|
|
|
150
367
|
function readOnlySchemaVersion(path, busyTimeoutMs) {
|
|
151
368
|
const deadline = Date.now() + busyTimeoutMs;
|
|
152
369
|
for (let delay = 1;; delay = Math.min(delay * 2, 32)) {
|
|
370
|
+
// A -wal without its -shm: only a writer could rebuild the index, so the version comes from
|
|
371
|
+
// the image straight away. A read-only connection would refuse it on POSIX, but on Windows it
|
|
372
|
+
// creates the -shm or answers SQLITE_PROTOCOL.
|
|
373
|
+
if (existsSync(`${path}-wal`) && !existsSync(`${path}-shm`))
|
|
374
|
+
return imageSchemaVersion(path);
|
|
153
375
|
const sidecars = existsSync(`${path}-wal`) || existsSync(`${path}-shm`);
|
|
154
376
|
const parameters = sidecars ? "mode=ro&readonly_shm=1" : "mode=ro&immutable=1";
|
|
155
377
|
let db;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** One access control entry, as the rules need it. */
|
|
2
|
+
export interface Ace {
|
|
3
|
+
readonly allow: boolean;
|
|
4
|
+
/** The entry is only inherited: it gives the folder itself nothing. */
|
|
5
|
+
readonly inheritOnly: boolean;
|
|
6
|
+
/** Subfolders inherit the entry. */
|
|
7
|
+
readonly containerInherit: boolean;
|
|
8
|
+
readonly mask: number;
|
|
9
|
+
/** A SID, or an SDDL alias this module does not resolve (domain-relative ones: never trusted). */
|
|
10
|
+
readonly sid: string;
|
|
11
|
+
}
|
|
12
|
+
/** A folder on the path to the store folder, with its access list as `icacls /save` writes it. */
|
|
13
|
+
export interface ChainFolder {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly dacl: string;
|
|
16
|
+
/** The folder the store folder is made in. */
|
|
17
|
+
readonly holdsStoreFolder: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The accounts whose rights on the chain refuse nothing: the user, SYSTEM, Administrators,
|
|
21
|
+
* TrustedInstaller, and CREATOR OWNER, which stands for whoever makes the folder, here the user.
|
|
22
|
+
* SDDL writes the built-in Administrator account (RID 500) as `LA`; it is the user only when the
|
|
23
|
+
* user is that account.
|
|
24
|
+
*/
|
|
25
|
+
export declare function trustedAccounts(userSid: string): ReadonlySet<string>;
|
|
26
|
+
/**
|
|
27
|
+
* The entries of an SDDL access list (`D:` and its flags, then one parenthesised entry each), or
|
|
28
|
+
* null for a null DACL, which grants everyone everything. Anything this parser does not know is
|
|
29
|
+
* thrown, never skipped: a skipped entry could be the one that grants another account access.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseDacl(sddl: string): Ace[] | null;
|
|
32
|
+
/**
|
|
33
|
+
* The folders whose access lists decide whether the store folder's path holds: its parent up to
|
|
34
|
+
* the drive root, written as given (an 8.3 short name stays short). Never the canonical path: a
|
|
35
|
+
* folder on this path that another account may swap would go unread, and the ACL steps use this
|
|
36
|
+
* path. Every folder here is read where it lies, so no other account can rename or replace one
|
|
37
|
+
* without a right the rules see: DELETE on it, or DELETE_CHILD on the folder above.
|
|
38
|
+
*/
|
|
39
|
+
export declare function storeFolderChain(storeFolder: string): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Why another account could change the path to the store folder, or undefined when none could.
|
|
42
|
+
* R1 to R4 are the rules of docs/specs/w-nv1/spec.md; the first folder that breaks one is named.
|
|
43
|
+
*/
|
|
44
|
+
export declare function chainRefusal(chain: readonly ChainFolder[], trusted: ReadonlySet<string>): string | undefined;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether another account could change the folders that hold a Windows store folder, read from
|
|
3
|
+
* their access lists as SDDL (docs/specs/w-nv1/spec.md). relembra secures a new store's folder by
|
|
4
|
+
* its path, which holds only while no other account can rename, delete, re-permission or pre-create
|
|
5
|
+
* a folder on that path, or open the new folder before its ACL is set. Pure: the caller reads the
|
|
6
|
+
* access lists with icacls.
|
|
7
|
+
*/
|
|
8
|
+
import { win32 } from "node:path";
|
|
9
|
+
const EVERYONE = "S-1-1-0";
|
|
10
|
+
const SYSTEM = "S-1-5-18";
|
|
11
|
+
const ADMINISTRATORS = "S-1-5-32-544";
|
|
12
|
+
const CREATOR_OWNER = "S-1-3-0";
|
|
13
|
+
const TRUSTED_INSTALLER = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
|
|
14
|
+
/** The well-known SDDL SID aliases whose SID does not depend on a domain. */
|
|
15
|
+
const ALIASES = {
|
|
16
|
+
AC: "S-1-15-2-1",
|
|
17
|
+
AN: "S-1-5-7",
|
|
18
|
+
AO: "S-1-5-32-548",
|
|
19
|
+
AU: "S-1-5-11",
|
|
20
|
+
BA: ADMINISTRATORS,
|
|
21
|
+
BG: "S-1-5-32-546",
|
|
22
|
+
BO: "S-1-5-32-551",
|
|
23
|
+
BU: "S-1-5-32-545",
|
|
24
|
+
CG: "S-1-3-1",
|
|
25
|
+
CO: CREATOR_OWNER,
|
|
26
|
+
ED: "S-1-5-9",
|
|
27
|
+
IU: "S-1-5-4",
|
|
28
|
+
LS: "S-1-5-19",
|
|
29
|
+
NO: "S-1-5-32-556",
|
|
30
|
+
NS: "S-1-5-20",
|
|
31
|
+
NU: "S-1-5-2",
|
|
32
|
+
OW: "S-1-3-4",
|
|
33
|
+
PS: "S-1-5-10",
|
|
34
|
+
PU: "S-1-5-32-547",
|
|
35
|
+
RC: "S-1-5-12",
|
|
36
|
+
RD: "S-1-5-32-555",
|
|
37
|
+
RU: "S-1-5-32-554",
|
|
38
|
+
SO: "S-1-5-32-549",
|
|
39
|
+
SU: "S-1-5-6",
|
|
40
|
+
SY: SYSTEM,
|
|
41
|
+
WD: EVERYONE,
|
|
42
|
+
WR: "S-1-5-33",
|
|
43
|
+
};
|
|
44
|
+
/** SDDL's two-letter access rights. */
|
|
45
|
+
const RIGHTS = {
|
|
46
|
+
GA: 0x10000000,
|
|
47
|
+
GX: 0x20000000,
|
|
48
|
+
GW: 0x40000000,
|
|
49
|
+
GR: 0x80000000,
|
|
50
|
+
RC: 0x20000,
|
|
51
|
+
SD: 0x10000,
|
|
52
|
+
WD: 0x40000,
|
|
53
|
+
WO: 0x80000,
|
|
54
|
+
FA: 0x1f01ff,
|
|
55
|
+
FR: 0x120089,
|
|
56
|
+
FW: 0x120116,
|
|
57
|
+
FX: 0x1200a0,
|
|
58
|
+
KA: 0xf003f,
|
|
59
|
+
KR: 0x20019,
|
|
60
|
+
KW: 0x20006,
|
|
61
|
+
KX: 0x20019,
|
|
62
|
+
CC: 0x1,
|
|
63
|
+
DC: 0x2,
|
|
64
|
+
LC: 0x4,
|
|
65
|
+
SW: 0x8,
|
|
66
|
+
RP: 0x10,
|
|
67
|
+
WP: 0x20,
|
|
68
|
+
DT: 0x40,
|
|
69
|
+
LO: 0x80,
|
|
70
|
+
CR: 0x100,
|
|
71
|
+
};
|
|
72
|
+
const FLAGS = new Set(["OI", "CI", "NP", "IO", "ID", "SA", "FA", "TP", "CR"]);
|
|
73
|
+
const ALLOW_TYPES = new Set(["A", "XA", "OA", "ZA"]);
|
|
74
|
+
const DENY_TYPES = new Set(["D", "XD", "OD"]);
|
|
75
|
+
/** Entry types that may end in a condition; their allow ones count as unconditional here. */
|
|
76
|
+
const CALLBACK_TYPES = new Set(["XA", "XD", "ZA"]);
|
|
77
|
+
const GUID = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/;
|
|
78
|
+
const DELETE = 0x10000;
|
|
79
|
+
const WRITE_DAC = 0x40000;
|
|
80
|
+
const WRITE_OWNER = 0x80000;
|
|
81
|
+
const DELETE_CHILD = 0x40;
|
|
82
|
+
const ADD_SUBDIRECTORY = 0x4;
|
|
83
|
+
const GENERIC_ALL = 0x10000000;
|
|
84
|
+
const GENERIC_WRITE = 0x40000000;
|
|
85
|
+
/** Read and execute: list, read attributes and the access list, traverse, synchronize. */
|
|
86
|
+
const READ_EXECUTE = 0x1200a9 | 0x80000000 | 0x20000000;
|
|
87
|
+
/**
|
|
88
|
+
* The accounts whose rights on the chain refuse nothing: the user, SYSTEM, Administrators,
|
|
89
|
+
* TrustedInstaller, and CREATOR OWNER, which stands for whoever makes the folder, here the user.
|
|
90
|
+
* SDDL writes the built-in Administrator account (RID 500) as `LA`; it is the user only when the
|
|
91
|
+
* user is that account.
|
|
92
|
+
*/
|
|
93
|
+
export function trustedAccounts(userSid) {
|
|
94
|
+
const trusted = new Set([userSid, SYSTEM, ADMINISTRATORS, TRUSTED_INSTALLER, CREATOR_OWNER]);
|
|
95
|
+
if (/^S-1-5-21-[0-9-]+-500$/.test(userSid))
|
|
96
|
+
trusted.add("LA");
|
|
97
|
+
return trusted;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The entries of an SDDL access list (`D:` and its flags, then one parenthesised entry each), or
|
|
101
|
+
* null for a null DACL, which grants everyone everything. Anything this parser does not know is
|
|
102
|
+
* thrown, never skipped: a skipped entry could be the one that grants another account access.
|
|
103
|
+
*/
|
|
104
|
+
export function parseDacl(sddl) {
|
|
105
|
+
const body = /^D:((?:P|AI|AR|NO_ACCESS_CONTROL)*)(.*)$/s.exec(sddl.trim());
|
|
106
|
+
if (body === null)
|
|
107
|
+
throw new Error(`not an SDDL access list: ${sddl}`);
|
|
108
|
+
const [, controlFlags = "", rest = ""] = body;
|
|
109
|
+
if (controlFlags.includes("NO_ACCESS_CONTROL"))
|
|
110
|
+
return null;
|
|
111
|
+
const aces = [];
|
|
112
|
+
let index = 0;
|
|
113
|
+
while (index < rest.length) {
|
|
114
|
+
// `icacls /save` writes the SACL, the mandatory label, on the same line: it ends the DACL.
|
|
115
|
+
if (rest.startsWith("S:", index))
|
|
116
|
+
break;
|
|
117
|
+
if (rest[index] !== "(")
|
|
118
|
+
throw new Error(`unexpected text in access list: ${rest.slice(index)}`);
|
|
119
|
+
// A conditional entry holds parentheses of its own, so the entry ends where depth returns to 0,
|
|
120
|
+
// counting none inside a quoted string, which SDDL delimits with `"` and never escapes.
|
|
121
|
+
let depth = 0;
|
|
122
|
+
let quoted = false;
|
|
123
|
+
let end = index;
|
|
124
|
+
for (; end < rest.length; end++) {
|
|
125
|
+
const char = rest[end];
|
|
126
|
+
if (char === '"')
|
|
127
|
+
quoted = !quoted;
|
|
128
|
+
else if (quoted)
|
|
129
|
+
continue;
|
|
130
|
+
else if (char === "(")
|
|
131
|
+
depth++;
|
|
132
|
+
else if (char === ")" && --depth === 0)
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (depth !== 0 || quoted) {
|
|
136
|
+
throw new Error(`unclosed entry in access list: ${rest.slice(index)}`);
|
|
137
|
+
}
|
|
138
|
+
aces.push(parseAce(rest.slice(index + 1, end)));
|
|
139
|
+
index = end + 1;
|
|
140
|
+
}
|
|
141
|
+
return aces;
|
|
142
|
+
}
|
|
143
|
+
function parseAce(text) {
|
|
144
|
+
const fields = text.split(";");
|
|
145
|
+
const [type = "", flags = "", rights = "", objectGuid = "", inheritGuid = "", sid = ""] = fields;
|
|
146
|
+
const allow = ALLOW_TYPES.has(type);
|
|
147
|
+
if (!allow && !DENY_TYPES.has(type))
|
|
148
|
+
throw new Error(`unknown access entry type: (${text})`);
|
|
149
|
+
// Only a callback entry may carry a condition, its seventh field; its text may hold `;` and
|
|
150
|
+
// quotes of its own. Every other field is checked, so no quote can hide in one.
|
|
151
|
+
const condition = fields.slice(6).join(";");
|
|
152
|
+
const conditionOk = CALLBACK_TYPES.has(type)
|
|
153
|
+
? fields.length === 6 || (condition.startsWith("(") && condition.endsWith(")"))
|
|
154
|
+
: fields.length === 6;
|
|
155
|
+
if (fields.length < 6 || !conditionOk)
|
|
156
|
+
throw new Error(`malformed access entry: (${text})`);
|
|
157
|
+
if (![objectGuid, inheritGuid].every((guid) => guid === "" || GUID.test(guid))) {
|
|
158
|
+
throw new Error(`malformed GUID in access entry: (${text})`);
|
|
159
|
+
}
|
|
160
|
+
const flagList = flags.match(/../g) ?? [];
|
|
161
|
+
if (flagList.join("") !== flags || flagList.some((flag) => !FLAGS.has(flag))) {
|
|
162
|
+
throw new Error(`unknown access entry flags: (${text})`);
|
|
163
|
+
}
|
|
164
|
+
if (!/^(S-1-[0-9-]+|[A-Z]{2})$/.test(sid))
|
|
165
|
+
throw new Error(`unknown SID in access entry: (${text})`);
|
|
166
|
+
return {
|
|
167
|
+
allow,
|
|
168
|
+
inheritOnly: flagList.includes("IO"),
|
|
169
|
+
containerInherit: flagList.includes("CI"),
|
|
170
|
+
mask: parseRights(rights, text),
|
|
171
|
+
sid: ALIASES[sid] ?? sid,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function parseRights(rights, text) {
|
|
175
|
+
if (/^0x[0-9a-fA-F]+$/.test(rights))
|
|
176
|
+
return Number.parseInt(rights.slice(2), 16);
|
|
177
|
+
const letters = rights.match(/../g) ?? [];
|
|
178
|
+
if (letters.length === 0 || letters.join("") !== rights) {
|
|
179
|
+
throw new Error(`unknown access rights: (${text})`);
|
|
180
|
+
}
|
|
181
|
+
let mask = 0;
|
|
182
|
+
for (const letter of letters) {
|
|
183
|
+
const right = RIGHTS[letter];
|
|
184
|
+
if (right === undefined)
|
|
185
|
+
throw new Error(`unknown access right ${letter}: (${text})`);
|
|
186
|
+
// Rights above bit 30 make a JavaScript bitwise result negative, so they are added.
|
|
187
|
+
mask = (mask | right) >>> 0;
|
|
188
|
+
}
|
|
189
|
+
return mask;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The folders whose access lists decide whether the store folder's path holds: its parent up to
|
|
193
|
+
* the drive root, written as given (an 8.3 short name stays short). Never the canonical path: a
|
|
194
|
+
* folder on this path that another account may swap would go unread, and the ACL steps use this
|
|
195
|
+
* path. Every folder here is read where it lies, so no other account can rename or replace one
|
|
196
|
+
* without a right the rules see: DELETE on it, or DELETE_CHILD on the folder above.
|
|
197
|
+
*/
|
|
198
|
+
export function storeFolderChain(storeFolder) {
|
|
199
|
+
const chain = [];
|
|
200
|
+
let folder = win32.resolve(storeFolder);
|
|
201
|
+
for (let up = win32.dirname(folder); up !== folder; up = win32.dirname(folder)) {
|
|
202
|
+
chain.push(up);
|
|
203
|
+
folder = up;
|
|
204
|
+
}
|
|
205
|
+
return chain;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Why another account could change the path to the store folder, or undefined when none could.
|
|
209
|
+
* R1 to R4 are the rules of docs/specs/w-nv1/spec.md; the first folder that breaks one is named.
|
|
210
|
+
*/
|
|
211
|
+
export function chainRefusal(chain, trusted) {
|
|
212
|
+
for (const folder of chain) {
|
|
213
|
+
const aces = parseDacl(folder.dacl) ?? [
|
|
214
|
+
{ allow: true, inheritOnly: false, containerInherit: true, mask: 0x1f01ff, sid: EVERYONE },
|
|
215
|
+
];
|
|
216
|
+
for (const ace of aces) {
|
|
217
|
+
if (!ace.allow || trusted.has(ace.sid))
|
|
218
|
+
continue;
|
|
219
|
+
const refuse = (what) => `${folder.path} lets another account (${ace.sid}) ${what}`;
|
|
220
|
+
const own = ace.inheritOnly ? 0 : ace.mask;
|
|
221
|
+
if (own & (DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_ALL)) {
|
|
222
|
+
return refuse("rename, delete or re-permission it");
|
|
223
|
+
}
|
|
224
|
+
if (own & DELETE_CHILD)
|
|
225
|
+
return refuse("rename or delete the folders in it");
|
|
226
|
+
if (!folder.holdsStoreFolder)
|
|
227
|
+
continue;
|
|
228
|
+
if (own & (ADD_SUBDIRECTORY | GENERIC_WRITE))
|
|
229
|
+
return refuse("create the store folder first");
|
|
230
|
+
if (ace.containerInherit && (ace.mask & ~READ_EXECUTE) >>> 0 !== 0) {
|
|
231
|
+
return refuse("open the new store folder before its ACL is set");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This relembra's version, as package.json gives it. An export's header carries it. */
|
|
2
|
-
export declare const RELEMBRA_VERSION = "1.0.0-beta.
|
|
2
|
+
export declare const RELEMBRA_VERSION = "1.0.0-beta.3";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This relembra's version, as package.json gives it. An export's header carries it. */
|
|
2
|
-
export const RELEMBRA_VERSION = "1.0.0-beta.
|
|
2
|
+
export const RELEMBRA_VERSION = "1.0.0-beta.3";
|
package/docs/FORMAT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# relembra storage format
|
|
2
2
|
|
|
3
|
-
Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.
|
|
3
|
+
Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.3.
|
|
4
4
|
|
|
5
5
|
This document is the contract for what relembra stores. A reader that follows it needs no
|
|
6
6
|
relembra code.
|
|
@@ -175,7 +175,7 @@ the events above, so a reader needs only this document.
|
|
|
175
175
|
|---|---|---|
|
|
176
176
|
| `export` | string | Always `relembra`. What makes the file an export. |
|
|
177
177
|
| `format` | integer | The newest event format of the lines after it: `1` when none is a `promote` line, so a format 1 reader still reads the file, and `2` when one is. |
|
|
178
|
-
| `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.
|
|
178
|
+
| `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.3`. Informational: a reader decides by `format` alone. |
|
|
179
179
|
| `scopes` | array of strings | The scopes exported. Every event's `scope` is one of them. |
|
|
180
180
|
|
|
181
181
|
### Events (lines 2 onward)
|