relembra 1.0.0-beta.2 → 1.0.0-beta.4
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.d.ts +3 -17
- package/dist/sqlite/database.js +43 -48
- package/dist/sqlite/windows-acl.d.ts +45 -0
- package/dist/sqlite/windows-acl.js +244 -0
- package/dist/sqlite/windows-security.d.ts +23 -0
- package/dist/sqlite/windows-security.js +256 -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.4, 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.4"`); 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
|
|
|
@@ -33,9 +33,9 @@ export declare function checkBusyTimeout(busyTimeoutMs: number): number;
|
|
|
33
33
|
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
34
34
|
export declare const OWNER_MODES: boolean;
|
|
35
35
|
/**
|
|
36
|
-
* Creates
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
|
|
37
|
+
* directory, created with a private descriptor, and the empty store file is made in it under the
|
|
38
|
+
* same lock. Existing files keep their existing checks.
|
|
39
39
|
*/
|
|
40
40
|
export declare function createOwnerOnly(path: string): void;
|
|
41
41
|
/**
|
|
@@ -50,20 +50,6 @@ export declare function windowsSystem32(systemRoot: string | undefined): string;
|
|
|
50
50
|
* store's ACL both ask.
|
|
51
51
|
*/
|
|
52
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
53
|
/**
|
|
68
54
|
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
69
55
|
* A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
|
package/dist/sqlite/database.js
CHANGED
|
@@ -7,6 +7,8 @@ import { StoreBusyError } from "../errors.js";
|
|
|
7
7
|
import { MIGRATIONS, migrate, SchemaTooNewError, schemaVersion } from "./migrations.js";
|
|
8
8
|
export { StoreBusyError };
|
|
9
9
|
import { walImage } from "./wal-image.js";
|
|
10
|
+
import { chainRefusal, storeFolderChain, trustedAccounts } from "./windows-acl.js";
|
|
11
|
+
import { createStoreUnderLock, readWindowsChain } from "./windows-security.js";
|
|
10
12
|
/** Thrown when an existing store file, or its -wal or -shm file, grants group or others access. */
|
|
11
13
|
export class StoreFileModeError extends Error {
|
|
12
14
|
name = "StoreFileModeError";
|
|
@@ -56,31 +58,21 @@ export function checkBusyTimeout(busyTimeoutMs) {
|
|
|
56
58
|
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
57
59
|
export const OWNER_MODES = process.platform !== "win32";
|
|
58
60
|
/**
|
|
59
|
-
* Creates
|
|
60
|
-
*
|
|
61
|
-
*
|
|
61
|
+
* Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
|
|
62
|
+
* directory, created with a private descriptor, and the empty store file is made in it under the
|
|
63
|
+
* same lock. Existing files keep their existing checks.
|
|
62
64
|
*/
|
|
63
65
|
export function createOwnerOnly(path) {
|
|
64
|
-
|
|
66
|
+
if (OWNER_MODES)
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
65
68
|
if (isSymbolicLink(path))
|
|
66
69
|
throw new StoreFileTypeError(path);
|
|
67
|
-
// Windows ignores the modes:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
restrictFolderToOwner(dirname(path));
|
|
71
|
-
// A sidecar without its store was put there by someone: it keeps its own ACL, and Windows
|
|
72
|
-
// opens a file by its full path whatever the folder grants, so SQLite would write into a file
|
|
73
|
-
// another account can read. Checked after the ACL, when no other account can add one. A
|
|
74
|
-
// sidecar beside a store that exists by now belongs to another process that created the
|
|
75
|
-
// store first (its folder got the same three grants), so it is no plant.
|
|
76
|
-
for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) {
|
|
77
|
-
if (hasEntry(sidecar) && !hasEntry(path)) {
|
|
78
|
-
throw new Error(`${sidecar} exists without its store; no store is created beside it`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
70
|
+
// Windows ignores the modes: the directory must be private from creation, before any file.
|
|
71
|
+
if (!OWNER_MODES && !existsSync(path))
|
|
72
|
+
createWindowsStore(path);
|
|
82
73
|
try {
|
|
83
|
-
// O_EXCL never follows a link, so a link made since the check fails here with EEXIST.
|
|
74
|
+
// O_EXCL never follows a link, so a link made since the check fails here with EEXIST. On
|
|
75
|
+
// Windows the file exists by now, made under the store's lock, and this is a no-op.
|
|
84
76
|
closeSync(openSync(path, "wx", 0o600));
|
|
85
77
|
}
|
|
86
78
|
catch (error) {
|
|
@@ -131,38 +123,41 @@ export function windowsUserSid(systemRoot) {
|
|
|
131
123
|
return sid;
|
|
132
124
|
}
|
|
133
125
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
|
|
142
|
-
* ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
|
|
143
|
-
* Accounts are named by SID, so no account name or system language is involved. Any failure is
|
|
144
|
-
* thrown: a store is never created in a folder others may read.
|
|
126
|
+
* Creates an absent Windows store folder with its private descriptor supplied to mkdir, then the
|
|
127
|
+
* empty store file in it, under a mutex named for the account and folder: two processes opening
|
|
128
|
+
* a missing store at once both succeed, the second finding the first's file. Whether an existing
|
|
129
|
+
* folder is another creator's or a preexisting uninitialized one is decided under that lock, by
|
|
130
|
+
* the store file's presence: the latter is refused, since changing an ACL cannot revoke old
|
|
131
|
+
* handles. A sidecar without its store (a file another account could read, since Windows opens
|
|
132
|
+
* a file by its path whatever the folder grants) is refused there too, before the first byte.
|
|
145
133
|
*/
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
134
|
+
function createWindowsStore(path) {
|
|
135
|
+
const store = win32.resolve(path);
|
|
136
|
+
const folder = win32.dirname(store);
|
|
137
|
+
if (!/^[A-Za-z]:\\/.test(store) || store.slice(2).includes(":")) {
|
|
138
|
+
throw new Error("a Windows store requires a local drive path without alternate streams");
|
|
139
|
+
}
|
|
140
|
+
if (hasEntry(folder) && lstatSync(folder).isSymbolicLink())
|
|
141
|
+
throw new Error(`${folder} is a link`);
|
|
150
142
|
const systemRoot = process.env["SystemRoot"];
|
|
151
143
|
const sid = windowsUserSid(systemRoot);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (entries.length !== accounts.length) {
|
|
164
|
-
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`);
|
|
144
|
+
const executable = win32.join(windowsSystem32(systemRoot), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
145
|
+
const ancestors = storeFolderChain(folder);
|
|
146
|
+
const missing = [folder];
|
|
147
|
+
const existing = [];
|
|
148
|
+
for (const ancestor of ancestors) {
|
|
149
|
+
if (hasEntry(ancestor))
|
|
150
|
+
existing.push(ancestor);
|
|
151
|
+
else if (existing.length === 0)
|
|
152
|
+
missing.push(ancestor);
|
|
153
|
+
else
|
|
154
|
+
throw new Error("the Windows store ancestry changed during verification");
|
|
165
155
|
}
|
|
156
|
+
const chain = readWindowsChain(executable, existing.reverse());
|
|
157
|
+
const refusal = chainRefusal(chain, trustedAccounts(sid));
|
|
158
|
+
if (refusal !== undefined)
|
|
159
|
+
throw new Error(`${refusal}; no store is created under it`);
|
|
160
|
+
createStoreUnderLock(executable, store, folder, missing.reverse(), chain, sid, storeFolderAccounts(sid));
|
|
166
161
|
}
|
|
167
162
|
/**
|
|
168
163
|
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/** An existing ancestor with its owner SID and DACL read directly from the filesystem. */
|
|
13
|
+
export interface ChainFolder {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly dacl: string;
|
|
16
|
+
readonly ownerSid: string;
|
|
17
|
+
/** The folder the store folder is made in. */
|
|
18
|
+
readonly holdsStoreFolder: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The accounts whose rights on the chain refuse nothing: the user, SYSTEM, Administrators,
|
|
22
|
+
* TrustedInstaller, and CREATOR OWNER, which stands for whoever makes the folder, here the user.
|
|
23
|
+
* SDDL writes the built-in Administrator account (RID 500) as `LA`; it is the user only when the
|
|
24
|
+
* user is that account.
|
|
25
|
+
*/
|
|
26
|
+
export declare function trustedAccounts(userSid: string): ReadonlySet<string>;
|
|
27
|
+
/**
|
|
28
|
+
* The entries of an SDDL access list (`D:` and its flags, then one parenthesised entry each), or
|
|
29
|
+
* null for a null DACL, which grants everyone everything. Anything this parser does not know is
|
|
30
|
+
* thrown, never skipped: a skipped entry could be the one that grants another account access.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseDacl(sddl: string): Ace[] | null;
|
|
33
|
+
/**
|
|
34
|
+
* The folders whose access lists decide whether the store folder's path holds: its parent up to
|
|
35
|
+
* the drive root, written as given (an 8.3 short name stays short). Never the canonical path: a
|
|
36
|
+
* folder on this path that another account may swap would go unread, and the ACL steps use this
|
|
37
|
+
* path. Every folder here is read where it lies, so no other account can rename or replace one
|
|
38
|
+
* without a right the rules see: DELETE on it, or DELETE_CHILD on the folder above.
|
|
39
|
+
*/
|
|
40
|
+
export declare function storeFolderChain(storeFolder: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Why another account could change the path to the store folder, or undefined when none could.
|
|
43
|
+
* R1 to R4 are the rules of docs/specs/w-nv1/spec.md; the first folder that breaks one is named.
|
|
44
|
+
*/
|
|
45
|
+
export declare function chainRefusal(chain: readonly ChainFolder[], trusted: ReadonlySet<string>): string | undefined;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current owners and access lists of the existing ancestry of a new Windows store directory.
|
|
3
|
+
* The filesystem adapter reads SIDs and SDDL directly through .NET; these conservative rules
|
|
4
|
+
* neither adopt preexisting store directories nor attest to historical ancestor handles.
|
|
5
|
+
*/
|
|
6
|
+
import { win32 } from "node:path";
|
|
7
|
+
const EVERYONE = "S-1-1-0";
|
|
8
|
+
const SYSTEM = "S-1-5-18";
|
|
9
|
+
const ADMINISTRATORS = "S-1-5-32-544";
|
|
10
|
+
const CREATOR_OWNER = "S-1-3-0";
|
|
11
|
+
const TRUSTED_INSTALLER = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
|
|
12
|
+
/** The well-known SDDL SID aliases whose SID does not depend on a domain. */
|
|
13
|
+
const ALIASES = {
|
|
14
|
+
AC: "S-1-15-2-1",
|
|
15
|
+
AN: "S-1-5-7",
|
|
16
|
+
AO: "S-1-5-32-548",
|
|
17
|
+
AU: "S-1-5-11",
|
|
18
|
+
BA: ADMINISTRATORS,
|
|
19
|
+
BG: "S-1-5-32-546",
|
|
20
|
+
BO: "S-1-5-32-551",
|
|
21
|
+
BU: "S-1-5-32-545",
|
|
22
|
+
CG: "S-1-3-1",
|
|
23
|
+
CO: CREATOR_OWNER,
|
|
24
|
+
ED: "S-1-5-9",
|
|
25
|
+
IU: "S-1-5-4",
|
|
26
|
+
LS: "S-1-5-19",
|
|
27
|
+
NO: "S-1-5-32-556",
|
|
28
|
+
NS: "S-1-5-20",
|
|
29
|
+
NU: "S-1-5-2",
|
|
30
|
+
OW: "S-1-3-4",
|
|
31
|
+
PS: "S-1-5-10",
|
|
32
|
+
PU: "S-1-5-32-547",
|
|
33
|
+
RC: "S-1-5-12",
|
|
34
|
+
RD: "S-1-5-32-555",
|
|
35
|
+
RU: "S-1-5-32-554",
|
|
36
|
+
SO: "S-1-5-32-549",
|
|
37
|
+
SU: "S-1-5-6",
|
|
38
|
+
SY: SYSTEM,
|
|
39
|
+
WD: EVERYONE,
|
|
40
|
+
WR: "S-1-5-33",
|
|
41
|
+
};
|
|
42
|
+
/** SDDL's two-letter access rights. */
|
|
43
|
+
const RIGHTS = {
|
|
44
|
+
GA: 0x10000000,
|
|
45
|
+
GX: 0x20000000,
|
|
46
|
+
GW: 0x40000000,
|
|
47
|
+
GR: 0x80000000,
|
|
48
|
+
RC: 0x20000,
|
|
49
|
+
SD: 0x10000,
|
|
50
|
+
WD: 0x40000,
|
|
51
|
+
WO: 0x80000,
|
|
52
|
+
FA: 0x1f01ff,
|
|
53
|
+
FR: 0x120089,
|
|
54
|
+
FW: 0x120116,
|
|
55
|
+
FX: 0x1200a0,
|
|
56
|
+
KA: 0xf003f,
|
|
57
|
+
KR: 0x20019,
|
|
58
|
+
KW: 0x20006,
|
|
59
|
+
KX: 0x20019,
|
|
60
|
+
CC: 0x1,
|
|
61
|
+
DC: 0x2,
|
|
62
|
+
LC: 0x4,
|
|
63
|
+
SW: 0x8,
|
|
64
|
+
RP: 0x10,
|
|
65
|
+
WP: 0x20,
|
|
66
|
+
DT: 0x40,
|
|
67
|
+
LO: 0x80,
|
|
68
|
+
CR: 0x100,
|
|
69
|
+
};
|
|
70
|
+
const FLAGS = new Set(["OI", "CI", "NP", "IO", "ID", "SA", "FA", "TP", "CR"]);
|
|
71
|
+
const ALLOW_TYPES = new Set(["A", "XA", "OA", "ZA"]);
|
|
72
|
+
const DENY_TYPES = new Set(["D", "XD", "OD"]);
|
|
73
|
+
/** Entry types that may end in a condition; their allow ones count as unconditional here. */
|
|
74
|
+
const CALLBACK_TYPES = new Set(["XA", "XD", "ZA"]);
|
|
75
|
+
const GUID = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/;
|
|
76
|
+
const WRITE_ATTRIBUTES = 0x100;
|
|
77
|
+
const DELETE = 0x10000;
|
|
78
|
+
const WRITE_DAC = 0x40000;
|
|
79
|
+
const WRITE_OWNER = 0x80000;
|
|
80
|
+
const DELETE_CHILD = 0x40;
|
|
81
|
+
const ADD_SUBDIRECTORY = 0x4;
|
|
82
|
+
const GENERIC_ALL = 0x10000000;
|
|
83
|
+
const GENERIC_WRITE = 0x40000000;
|
|
84
|
+
/** Read and execute: list, read attributes and the access list, traverse, synchronize. */
|
|
85
|
+
const READ_EXECUTE = 0x1200a9 | 0x80000000 | 0x20000000;
|
|
86
|
+
/**
|
|
87
|
+
* The accounts whose rights on the chain refuse nothing: the user, SYSTEM, Administrators,
|
|
88
|
+
* TrustedInstaller, and CREATOR OWNER, which stands for whoever makes the folder, here the user.
|
|
89
|
+
* SDDL writes the built-in Administrator account (RID 500) as `LA`; it is the user only when the
|
|
90
|
+
* user is that account.
|
|
91
|
+
*/
|
|
92
|
+
export function trustedAccounts(userSid) {
|
|
93
|
+
const trusted = new Set([userSid, SYSTEM, ADMINISTRATORS, TRUSTED_INSTALLER, CREATOR_OWNER]);
|
|
94
|
+
if (/^S-1-5-21-[0-9-]+-500$/.test(userSid))
|
|
95
|
+
trusted.add("LA");
|
|
96
|
+
return trusted;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The entries of an SDDL access list (`D:` and its flags, then one parenthesised entry each), or
|
|
100
|
+
* null for a null DACL, which grants everyone everything. Anything this parser does not know is
|
|
101
|
+
* thrown, never skipped: a skipped entry could be the one that grants another account access.
|
|
102
|
+
*/
|
|
103
|
+
export function parseDacl(sddl) {
|
|
104
|
+
const body = /^D:((?:P|AI|AR|NO_ACCESS_CONTROL)*)(.*)$/s.exec(sddl.trim());
|
|
105
|
+
if (body === null)
|
|
106
|
+
throw new Error(`not an SDDL access list: ${sddl}`);
|
|
107
|
+
const [, controlFlags = "", rest = ""] = body;
|
|
108
|
+
if (controlFlags.includes("NO_ACCESS_CONTROL"))
|
|
109
|
+
return null;
|
|
110
|
+
const aces = [];
|
|
111
|
+
let index = 0;
|
|
112
|
+
while (index < rest.length) {
|
|
113
|
+
// `icacls /save` writes the SACL, the mandatory label, on the same line: it ends the DACL.
|
|
114
|
+
if (rest.startsWith("S:", index))
|
|
115
|
+
break;
|
|
116
|
+
if (rest[index] !== "(")
|
|
117
|
+
throw new Error(`unexpected text in access list: ${rest.slice(index)}`);
|
|
118
|
+
// A conditional entry holds parentheses of its own, so the entry ends where depth returns to 0,
|
|
119
|
+
// counting none inside a quoted string, which SDDL delimits with `"` and never escapes.
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let quoted = false;
|
|
122
|
+
let end = index;
|
|
123
|
+
for (; end < rest.length; end++) {
|
|
124
|
+
const char = rest[end];
|
|
125
|
+
if (char === '"')
|
|
126
|
+
quoted = !quoted;
|
|
127
|
+
else if (quoted)
|
|
128
|
+
continue;
|
|
129
|
+
else if (char === "(")
|
|
130
|
+
depth++;
|
|
131
|
+
else if (char === ")" && --depth === 0)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
if (depth !== 0 || quoted) {
|
|
135
|
+
throw new Error(`unclosed entry in access list: ${rest.slice(index)}`);
|
|
136
|
+
}
|
|
137
|
+
aces.push(parseAce(rest.slice(index + 1, end)));
|
|
138
|
+
index = end + 1;
|
|
139
|
+
}
|
|
140
|
+
return aces;
|
|
141
|
+
}
|
|
142
|
+
function parseAce(text) {
|
|
143
|
+
const fields = text.split(";");
|
|
144
|
+
const [type = "", flags = "", rights = "", objectGuid = "", inheritGuid = "", sid = ""] = fields;
|
|
145
|
+
const allow = ALLOW_TYPES.has(type);
|
|
146
|
+
if (!allow && !DENY_TYPES.has(type))
|
|
147
|
+
throw new Error(`unknown access entry type: (${text})`);
|
|
148
|
+
// Only a callback entry may carry a condition, its seventh field; its text may hold `;` and
|
|
149
|
+
// quotes of its own. Every other field is checked, so no quote can hide in one.
|
|
150
|
+
const condition = fields.slice(6).join(";");
|
|
151
|
+
const conditionOk = CALLBACK_TYPES.has(type)
|
|
152
|
+
? fields.length === 6 || (condition.startsWith("(") && condition.endsWith(")"))
|
|
153
|
+
: fields.length === 6;
|
|
154
|
+
if (fields.length < 6 || !conditionOk)
|
|
155
|
+
throw new Error(`malformed access entry: (${text})`);
|
|
156
|
+
if (![objectGuid, inheritGuid].every((guid) => guid === "" || GUID.test(guid))) {
|
|
157
|
+
throw new Error(`malformed GUID in access entry: (${text})`);
|
|
158
|
+
}
|
|
159
|
+
const flagList = flags.match(/../g) ?? [];
|
|
160
|
+
if (flagList.join("") !== flags || flagList.some((flag) => !FLAGS.has(flag))) {
|
|
161
|
+
throw new Error(`unknown access entry flags: (${text})`);
|
|
162
|
+
}
|
|
163
|
+
if (!/^(S-1-[0-9-]+|[A-Z]{2})$/.test(sid))
|
|
164
|
+
throw new Error(`unknown SID in access entry: (${text})`);
|
|
165
|
+
return {
|
|
166
|
+
allow,
|
|
167
|
+
inheritOnly: flagList.includes("IO"),
|
|
168
|
+
containerInherit: flagList.includes("CI"),
|
|
169
|
+
mask: parseRights(rights, text),
|
|
170
|
+
sid: ALIASES[sid] ?? sid,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function parseRights(rights, text) {
|
|
174
|
+
if (/^0x[0-9a-fA-F]+$/.test(rights))
|
|
175
|
+
return Number.parseInt(rights.slice(2), 16);
|
|
176
|
+
const letters = rights.match(/../g) ?? [];
|
|
177
|
+
if (letters.length === 0 || letters.join("") !== rights) {
|
|
178
|
+
throw new Error(`unknown access rights: (${text})`);
|
|
179
|
+
}
|
|
180
|
+
let mask = 0;
|
|
181
|
+
for (const letter of letters) {
|
|
182
|
+
const right = RIGHTS[letter];
|
|
183
|
+
if (right === undefined)
|
|
184
|
+
throw new Error(`unknown access right ${letter}: (${text})`);
|
|
185
|
+
// Rights above bit 30 make a JavaScript bitwise result negative, so they are added.
|
|
186
|
+
mask = (mask | right) >>> 0;
|
|
187
|
+
}
|
|
188
|
+
return mask;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The folders whose access lists decide whether the store folder's path holds: its parent up to
|
|
192
|
+
* the drive root, written as given (an 8.3 short name stays short). Never the canonical path: a
|
|
193
|
+
* folder on this path that another account may swap would go unread, and the ACL steps use this
|
|
194
|
+
* path. Every folder here is read where it lies, so no other account can rename or replace one
|
|
195
|
+
* without a right the rules see: DELETE on it, or DELETE_CHILD on the folder above.
|
|
196
|
+
*/
|
|
197
|
+
export function storeFolderChain(storeFolder) {
|
|
198
|
+
const chain = [];
|
|
199
|
+
let folder = win32.resolve(storeFolder);
|
|
200
|
+
for (let up = win32.dirname(folder); up !== folder; up = win32.dirname(folder)) {
|
|
201
|
+
chain.push(up);
|
|
202
|
+
folder = up;
|
|
203
|
+
}
|
|
204
|
+
return chain;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Why another account could change the path to the store folder, or undefined when none could.
|
|
208
|
+
* R1 to R4 are the rules of docs/specs/w-nv1/spec.md; the first folder that breaks one is named.
|
|
209
|
+
*/
|
|
210
|
+
export function chainRefusal(chain, trusted) {
|
|
211
|
+
for (const folder of chain) {
|
|
212
|
+
if (!/^S-1-[0-9]+(?:-[0-9]+)+$/.test(folder.ownerSid) || folder.ownerSid === CREATOR_OWNER) {
|
|
213
|
+
return `${folder.path} has an unverifiable owner`;
|
|
214
|
+
}
|
|
215
|
+
if (!trusted.has(folder.ownerSid)) {
|
|
216
|
+
return `${folder.path} is owned by another account (${folder.ownerSid})`;
|
|
217
|
+
}
|
|
218
|
+
const aces = parseDacl(folder.dacl) ?? [
|
|
219
|
+
{ allow: true, inheritOnly: false, containerInherit: true, mask: 0x1f01ff, sid: EVERYONE },
|
|
220
|
+
];
|
|
221
|
+
for (const ace of aces) {
|
|
222
|
+
if (!ace.allow || trusted.has(ace.sid))
|
|
223
|
+
continue;
|
|
224
|
+
const refuse = (what) => `${folder.path} lets another account (${ace.sid}) ${what}`;
|
|
225
|
+
const own = ace.inheritOnly ? 0 : ace.mask;
|
|
226
|
+
if (own & (DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_ALL)) {
|
|
227
|
+
return refuse("rename, delete or re-permission it");
|
|
228
|
+
}
|
|
229
|
+
if (own & (WRITE_ATTRIBUTES | GENERIC_WRITE)) {
|
|
230
|
+
return refuse("change its attributes or turn it into a reparse point");
|
|
231
|
+
}
|
|
232
|
+
if (own & DELETE_CHILD)
|
|
233
|
+
return refuse("rename or delete the folders in it");
|
|
234
|
+
if (!folder.holdsStoreFolder)
|
|
235
|
+
continue;
|
|
236
|
+
if (own & (ADD_SUBDIRECTORY | GENERIC_WRITE))
|
|
237
|
+
return refuse("create the store folder first");
|
|
238
|
+
if (ace.containerInherit && (ace.mask & ~READ_EXECUTE) >>> 0 !== 0) {
|
|
239
|
+
return refuse("open the new store folder before its ACL is set");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChainFolder } from "./windows-acl.js";
|
|
2
|
+
/** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
|
|
3
|
+
export declare function readWindowsChain(executable: string, paths: readonly string[]): ChainFolder[];
|
|
4
|
+
/**
|
|
5
|
+
* The name of the mutex that serialises the creators of one store: one per account and store
|
|
6
|
+
* folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
|
|
7
|
+
* of one folder take two names, so two such creators refuse each other, never both create.
|
|
8
|
+
*/
|
|
9
|
+
export declare function storeLockName(ownerSid: string, folder: string): string;
|
|
10
|
+
/** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
|
|
11
|
+
export declare function storeLockSddl(ownerSid: string, accounts: readonly string[]): string;
|
|
12
|
+
/**
|
|
13
|
+
* The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
|
|
14
|
+
* (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
|
|
15
|
+
* icacls does. Without `AI` Windows copies the grants into new files unmarked.
|
|
16
|
+
*/
|
|
17
|
+
export declare function storeFolderSddl(ownerSid: string, accounts: readonly string[]): string;
|
|
18
|
+
/**
|
|
19
|
+
* Creates the absent folders with protected inheritable grants before they become visible, then
|
|
20
|
+
* the empty store file in the last, all under the store's lock. Returns `exists` when another
|
|
21
|
+
* creator finished the same store under that lock meanwhile.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createStoreUnderLock(executable: string, store: string, folder: string, paths: readonly string[], chain: readonly ChainFolder[], ownerSid: string, accounts: readonly string[]): "created" | "exists";
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Windows PowerShell uses .NET Framework: this overload supplies the descriptor to mkdir, and a
|
|
5
|
+
* named mutex serialises the creators of one store, so two first opens both succeed.
|
|
6
|
+
*/
|
|
7
|
+
const PROGRAM = String.raw `
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
10
|
+
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
11
|
+
[void][Reflection.Assembly]::Load('System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35')
|
|
12
|
+
$serializer = [System.Web.Script.Serialization.JavaScriptSerializer]::new()
|
|
13
|
+
function Read-Folder([string] $path) {
|
|
14
|
+
$attributes = [IO.File]::GetAttributes($path)
|
|
15
|
+
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$path is a link or reparse point" }
|
|
16
|
+
if (($attributes -band [IO.FileAttributes]::Directory) -eq 0) { throw "$path is not a directory" }
|
|
17
|
+
$sections = [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Access
|
|
18
|
+
$acl = [IO.Directory]::GetAccessControl($path, $sections)
|
|
19
|
+
@{
|
|
20
|
+
path = $path
|
|
21
|
+
ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
22
|
+
dacl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function Test-Entry([string] $path) {
|
|
26
|
+
[IO.Directory]::Exists($path) -or [IO.File]::Exists($path)
|
|
27
|
+
}
|
|
28
|
+
# Exactly one full-control allow per account, nothing else: a folder's own entries, a file's
|
|
29
|
+
# inherited ones. A folder is owned by the user, whose SID its descriptor names; a file by the
|
|
30
|
+
# token's default owner, which is Administrators for an administrator, so any of the accounts.
|
|
31
|
+
function Confirm-Rules($acl, [string] $what, [string[]] $owners) {
|
|
32
|
+
$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
|
33
|
+
if ($owners -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -or $rules.Count -ne $request.accounts.Count) {
|
|
34
|
+
throw "$what is not private"
|
|
35
|
+
}
|
|
36
|
+
$seen = @{}
|
|
37
|
+
foreach ($rule in $rules) {
|
|
38
|
+
$sid = $rule.IdentityReference.Value
|
|
39
|
+
if ($request.accounts -notcontains $sid -or $seen.ContainsKey($sid) -or
|
|
40
|
+
$rule.AccessControlType -ne 'Allow' -or
|
|
41
|
+
$rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl) {
|
|
42
|
+
throw "$what has unexpected access rules"
|
|
43
|
+
}
|
|
44
|
+
$seen[$sid] = $true
|
|
45
|
+
}
|
|
46
|
+
$rules
|
|
47
|
+
}
|
|
48
|
+
# With $model, the folder must also carry the auto-inherit model (AI), under which the files made
|
|
49
|
+
# in it carry its grants as inherited entries.
|
|
50
|
+
function Confirm-Folder([string] $path, [bool] $model) {
|
|
51
|
+
$folder = Read-Folder $path
|
|
52
|
+
$acl = [IO.Directory]::GetAccessControl($path)
|
|
53
|
+
if (-not $acl.AreAccessRulesProtected) { throw "$path was not created private" }
|
|
54
|
+
foreach ($rule in (Confirm-Rules $acl $path @($request.ownerSid))) {
|
|
55
|
+
if ($rule.IsInherited -or
|
|
56
|
+
$rule.InheritanceFlags -ne ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit) -or
|
|
57
|
+
$rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) {
|
|
58
|
+
throw "$path has unexpected access rules"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if ($model -and -not $folder.dacl.StartsWith('D:PAI')) { throw "$path did not take the auto-inherit model" }
|
|
62
|
+
}
|
|
63
|
+
function Confirm-File([string] $path) {
|
|
64
|
+
$attributes = [IO.File]::GetAttributes($path)
|
|
65
|
+
if (($attributes -band ([IO.FileAttributes]::ReparsePoint -bor [IO.FileAttributes]::Directory)) -ne 0) { throw "$path is not a regular file" }
|
|
66
|
+
[void](Confirm-Rules ([IO.File]::GetAccessControl($path)) $path $request.accounts)
|
|
67
|
+
}
|
|
68
|
+
# The lock is ours only when its owner and every grant are: a squatted name is refused, never
|
|
69
|
+
# re-permissioned.
|
|
70
|
+
function Confirm-Lock($lock) {
|
|
71
|
+
$acl = $lock.GetAccessControl()
|
|
72
|
+
if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $request.ownerSid) { throw 'the store creation lock is owned by another account' }
|
|
73
|
+
$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
|
74
|
+
if ($rules.Count -ne $request.accounts.Count) { throw 'the store creation lock grants another account access' }
|
|
75
|
+
foreach ($rule in $rules) {
|
|
76
|
+
if ($request.accounts -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne 'Allow' -or
|
|
77
|
+
$rule.MutexRights -ne [System.Security.AccessControl.MutexRights]::FullControl) {
|
|
78
|
+
throw 'the store creation lock grants another account access'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
$request = $serializer.DeserializeObject([Console]::In.ReadToEnd())
|
|
84
|
+
if ($request.mode -eq 'read') {
|
|
85
|
+
$result = @(foreach ($path in $request.paths) { Read-Folder $path })
|
|
86
|
+
[Console]::Write($serializer.Serialize([object[]]$result))
|
|
87
|
+
} elseif ($request.mode -eq 'create') {
|
|
88
|
+
if ($request.lockName -notmatch '^Global\\relembra-[0-9a-f]{64}$') { throw 'invalid store creation lock name' }
|
|
89
|
+
$lockSecurity = [System.Security.AccessControl.MutexSecurity]::new()
|
|
90
|
+
$lockSecurity.SetSecurityDescriptorSddlForm($request.lockSddl)
|
|
91
|
+
$createdNew = $false
|
|
92
|
+
$lock = [System.Threading.Mutex]::new($false, $request.lockName, [ref] $createdNew, $lockSecurity)
|
|
93
|
+
$held = $false
|
|
94
|
+
try {
|
|
95
|
+
Confirm-Lock $lock
|
|
96
|
+
try { $held = $lock.WaitOne(10000) } catch [System.Threading.AbandonedMutexException] { $held = $true }
|
|
97
|
+
if (-not $held) { throw 'another process is still creating the store folder' }
|
|
98
|
+
if (Test-Entry $request.store) {
|
|
99
|
+
# Another creator finished under this lock: its folder must be exactly what this one would
|
|
100
|
+
# have made, model included. An abandoned lock never adopts an unfinished folder: the
|
|
101
|
+
# store is absent then.
|
|
102
|
+
Confirm-File $request.store
|
|
103
|
+
Confirm-Folder $request.folder $true
|
|
104
|
+
$result = 'exists'
|
|
105
|
+
} else {
|
|
106
|
+
foreach ($expected in $request.chain) {
|
|
107
|
+
$actual = Read-Folder $expected.path
|
|
108
|
+
if ($actual.ownerSid -ne $expected.ownerSid -or $actual.dacl -cne $expected.dacl) {
|
|
109
|
+
throw 'the store folder ancestry changed during verification'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
foreach ($path in $request.paths) {
|
|
113
|
+
if (Test-Entry $path) { throw "$path already exists; a new Windows store requires a new folder" }
|
|
114
|
+
}
|
|
115
|
+
# What this run made is removed when a later step fails, so a retry finds the leaf absent;
|
|
116
|
+
# nothing that existed before is ever removed.
|
|
117
|
+
$created = @()
|
|
118
|
+
$fileCreated = $false
|
|
119
|
+
try {
|
|
120
|
+
foreach ($path in $request.paths) {
|
|
121
|
+
# A fresh descriptor per call: Persist clears what a reused one would apply again.
|
|
122
|
+
$security = [Security.AccessControl.DirectorySecurity]::new()
|
|
123
|
+
$security.SetSecurityDescriptorSddlForm($request.sddl)
|
|
124
|
+
[void][IO.Directory]::CreateDirectory($path, $security)
|
|
125
|
+
$created += $path
|
|
126
|
+
Confirm-Folder $path $false
|
|
127
|
+
# Files made in the folder carry its grants as inherited entries only under the
|
|
128
|
+
# auto-inherit model (AI). Creation may hand the kernel a descriptor without it; setting
|
|
129
|
+
# the same descriptor again goes through SetNamedSecurityInfo, which adopts the model.
|
|
130
|
+
if (-not (Read-Folder $path).dacl.StartsWith('D:PAI')) {
|
|
131
|
+
$again = [Security.AccessControl.DirectorySecurity]::new()
|
|
132
|
+
$again.SetSecurityDescriptorSddlForm($request.sddl)
|
|
133
|
+
[IO.Directory]::SetAccessControl($path, $again)
|
|
134
|
+
}
|
|
135
|
+
Confirm-Folder $path $true
|
|
136
|
+
}
|
|
137
|
+
foreach ($sidecar in $request.sidecars) {
|
|
138
|
+
if (Test-Entry $sidecar) { throw "$sidecar exists without its store; no store is created beside it" }
|
|
139
|
+
}
|
|
140
|
+
[IO.FileStream]::new($request.store, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None).Dispose()
|
|
141
|
+
$fileCreated = $true
|
|
142
|
+
Confirm-File $request.store
|
|
143
|
+
} catch {
|
|
144
|
+
$failure = $_.Exception
|
|
145
|
+
if ($fileCreated) { try { [IO.File]::Delete($request.store) } catch { } }
|
|
146
|
+
for ($i = $created.Count - 1; $i -ge 0; $i--) { try { [IO.Directory]::Delete($created[$i]) } catch { } }
|
|
147
|
+
throw $failure
|
|
148
|
+
}
|
|
149
|
+
$result = 'created'
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
if ($held) { $lock.ReleaseMutex() }
|
|
153
|
+
$lock.Dispose()
|
|
154
|
+
}
|
|
155
|
+
[Console]::Write($serializer.Serialize($result))
|
|
156
|
+
} else { throw 'unknown security operation' }
|
|
157
|
+
} catch {
|
|
158
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
159
|
+
exit 1
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
function run(executable, request) {
|
|
163
|
+
const child = spawnSync(executable, [
|
|
164
|
+
"-NoLogo",
|
|
165
|
+
"-NoProfile",
|
|
166
|
+
"-NonInteractive",
|
|
167
|
+
"-EncodedCommand",
|
|
168
|
+
Buffer.from(PROGRAM, "utf16le").toString("base64"),
|
|
169
|
+
], {
|
|
170
|
+
input: JSON.stringify(request),
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
shell: false,
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
timeout: 30_000,
|
|
175
|
+
});
|
|
176
|
+
if (child.error !== undefined || child.status !== 0) {
|
|
177
|
+
throw new Error(`Windows store security failed: ${child.error?.message ?? child.stderr.trim()}; no store is created`);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(child.stdout.replace(/^/, ""));
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
throw new Error("Windows store security returned invalid JSON; no store is created", { cause });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
|
|
187
|
+
export function readWindowsChain(executable, paths) {
|
|
188
|
+
const result = run(executable, { mode: "read", paths });
|
|
189
|
+
if (!Array.isArray(result) || result.length !== paths.length) {
|
|
190
|
+
throw new Error("Windows store security did not return the complete folder chain");
|
|
191
|
+
}
|
|
192
|
+
return result.map((value, index) => {
|
|
193
|
+
if (typeof value !== "object" ||
|
|
194
|
+
value === null ||
|
|
195
|
+
!("path" in value) ||
|
|
196
|
+
typeof value.path !== "string" ||
|
|
197
|
+
value.path !== paths[index] ||
|
|
198
|
+
!("ownerSid" in value) ||
|
|
199
|
+
typeof value.ownerSid !== "string" ||
|
|
200
|
+
!("dacl" in value) ||
|
|
201
|
+
typeof value.dacl !== "string") {
|
|
202
|
+
throw new Error("Windows store security returned an invalid folder descriptor");
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
path: value.path,
|
|
206
|
+
ownerSid: value.ownerSid,
|
|
207
|
+
dacl: value.dacl,
|
|
208
|
+
holdsStoreFolder: index === paths.length - 1,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The name of the mutex that serialises the creators of one store: one per account and store
|
|
214
|
+
* folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
|
|
215
|
+
* of one folder take two names, so two such creators refuse each other, never both create.
|
|
216
|
+
*/
|
|
217
|
+
export function storeLockName(ownerSid, folder) {
|
|
218
|
+
const digest = createHash("sha256").update(`${ownerSid}\n${folder.toLowerCase()}`).digest("hex");
|
|
219
|
+
return `Global\\relembra-${digest}`;
|
|
220
|
+
}
|
|
221
|
+
/** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
|
|
222
|
+
export function storeLockSddl(ownerSid, accounts) {
|
|
223
|
+
return `O:${ownerSid}D:P${accounts.map((sid) => `(A;;0x1f0001;;;${sid})`).join("")}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
|
|
227
|
+
* (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
|
|
228
|
+
* icacls does. Without `AI` Windows copies the grants into new files unmarked.
|
|
229
|
+
*/
|
|
230
|
+
export function storeFolderSddl(ownerSid, accounts) {
|
|
231
|
+
return `O:${ownerSid}D:PAI${accounts.map((sid) => `(A;OICI;FA;;;${sid})`).join("")}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Creates the absent folders with protected inheritable grants before they become visible, then
|
|
235
|
+
* the empty store file in the last, all under the store's lock. Returns `exists` when another
|
|
236
|
+
* creator finished the same store under that lock meanwhile.
|
|
237
|
+
*/
|
|
238
|
+
export function createStoreUnderLock(executable, store, folder, paths, chain, ownerSid, accounts) {
|
|
239
|
+
const result = run(executable, {
|
|
240
|
+
mode: "create",
|
|
241
|
+
store,
|
|
242
|
+
folder,
|
|
243
|
+
paths,
|
|
244
|
+
sidecars: [`${store}-wal`, `${store}-shm`, `${store}-journal`],
|
|
245
|
+
chain,
|
|
246
|
+
ownerSid,
|
|
247
|
+
accounts,
|
|
248
|
+
sddl: storeFolderSddl(ownerSid, accounts),
|
|
249
|
+
lockName: storeLockName(ownerSid, folder),
|
|
250
|
+
lockSddl: storeLockSddl(ownerSid, accounts),
|
|
251
|
+
});
|
|
252
|
+
if (result !== "created" && result !== "exists") {
|
|
253
|
+
throw new Error("Windows store security did not confirm private store creation");
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
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.4";
|
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.4";
|
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.4.
|
|
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.4`. 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)
|