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 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.1, a beta ([changelog](CHANGELOG.md),
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 the macOS arm64
207
- binary `relembra-darwin-arm64`, `SHA256SUMS` and the library archive npm serves. Download the
208
- binary and checksum file, then install them with `install.sh` from a checkout of the same tag:
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-darwin-arm64 SHA256SUMS # verifies the checksum, installs to ~/.local/bin
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.1"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
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
- On other platforms, build it from a clone with an AOT-capable Bun using `bun run build:cli`
221
- (writes `bin/relembra` and `bin/relembra.sha256`) and install that pair the same way. A local Mac
222
- check does not attest another platform's binary.
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
- `scripts/site/publish.sh` publishes a committed `site/` to the `gh-pages` branch for GitHub Pages
308
- (`--dry-run` shows the tree without pushing); the explainer video in `site/assets/video/` is
309
- git-ignored on `main` and ships only there.
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
 
@@ -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;
@@ -99,6 +99,7 @@ export const ENV_VARS = [
99
99
  "PATH",
100
100
  "HOME",
101
101
  "XDG_CONFIG_HOME",
102
+ "SystemRoot",
102
103
  ];
103
104
  export function usageText() {
104
105
  return [
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;
@@ -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
- const inside = (dir) => fences.some((f) => dir === f || dir.startsWith(`${f}/`));
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 = realpathSync(join(entry, "git"));
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
- return path;
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
- return `file:${safeRealpath(absolute).replace(/(?<=.)\/+$/, "")}`;
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 realpathSync(path);
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
@@ -1,11 +1,15 @@
1
- import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
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
- const mode = Number(stats.mode) & 0o777;
94
- if ((mode & 0o077) !== 0)
95
- throw new StoreFileModeError(file, mode);
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.1";
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.1";
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.1.
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.1`. Informational: a reader decides by `format` alone. |
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relembra",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "A curated, scoped, bounded, durable, erasable and measured memory for AI agents.",
5
5
  "license": "MIT",
6
6
  "author": "mneves75",