relembra 1.0.0-beta.2 → 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 +2 -2
- package/dist/sqlite/database.js +101 -2
- 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).
|
|
@@ -226,7 +226,7 @@ On Windows, check the hash with `Get-FileHash` and copy the binary onto `PATH` a
|
|
|
226
226
|
|
|
227
227
|
Before replacing an installed executable, retain a backup and its hash. Verify installed bytes
|
|
228
228
|
against the release checksum and export metadata from a disposable store
|
|
229
|
-
(`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
|
|
230
230
|
rollback without opening a live memory store. After verification, `relembra init` prints the
|
|
231
231
|
session hook for your agent.
|
|
232
232
|
|
package/dist/sqlite/database.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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";
|
|
4
6
|
import { DatabaseSync } from "node:sqlite";
|
|
5
7
|
import { pathToFileURL } from "node:url";
|
|
6
8
|
import { StoreBusyError } from "../errors.js";
|
|
7
9
|
import { MIGRATIONS, migrate, SchemaTooNewError, schemaVersion } from "./migrations.js";
|
|
8
10
|
export { StoreBusyError };
|
|
9
11
|
import { walImage } from "./wal-image.js";
|
|
12
|
+
import { chainRefusal, storeFolderChain, trustedAccounts, } from "./windows-acl.js";
|
|
10
13
|
/** Thrown when an existing store file, or its -wal or -shm file, grants group or others access. */
|
|
11
14
|
export class StoreFileModeError extends Error {
|
|
12
15
|
name = "StoreFileModeError";
|
|
@@ -149,6 +152,7 @@ export function restrictFolderToOwner(dir) {
|
|
|
149
152
|
throw new Error(`${dir} is a link; no store is created in it`);
|
|
150
153
|
const systemRoot = process.env["SystemRoot"];
|
|
151
154
|
const sid = windowsUserSid(systemRoot);
|
|
155
|
+
checkFolderChain(systemRoot, dir, sid);
|
|
152
156
|
const accounts = storeFolderAccounts(sid);
|
|
153
157
|
runSystemTool(systemRoot, "icacls.exe", [dir, "/setowner", `*${sid}`]);
|
|
154
158
|
runSystemTool(systemRoot, "icacls.exe", [
|
|
@@ -164,6 +168,101 @@ export function restrictFolderToOwner(dir) {
|
|
|
164
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`);
|
|
165
169
|
}
|
|
166
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
|
+
}
|
|
167
266
|
/**
|
|
168
267
|
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
169
268
|
* A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
|
|
@@ -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)
|