@robota-sdk/agent-file-authority 3.0.0-beta.81
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/LICENSE +661 -0
- package/README.md +22 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +26 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +26 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +76 -0
- package/src/__tests__/stable-rooted-file-reader.test.ts +240 -0
- package/src/__tests__/windows-native-authority.test.ts +113 -0
- package/src/contracts.ts +54 -0
- package/src/host-backend.ts +13 -0
- package/src/index.ts +6 -0
- package/src/posix-backend.ts +253 -0
- package/src/stable-rooted-file-reader.ts +124 -0
- package/src/windows-backend.ts +89 -0
- package/src/windows-native-api.ts +234 -0
- package/src/windows-native-authority.ts +227 -0
- package/src/windows-native-read.ts +122 -0
- package/src/windows-native-status.ts +41 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as koffi from 'koffi';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { WindowsNativeAuthority } from '../windows-native-authority.js';
|
|
5
|
+
|
|
6
|
+
import type { TNativeHandle } from '../windows-native-api.js';
|
|
7
|
+
|
|
8
|
+
function uninitializedAuthority(): WindowsNativeAuthority {
|
|
9
|
+
return Object.create(WindowsNativeAuthority.prototype) as WindowsNativeAuthority;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('WindowsNativeAuthority cleanup', () => {
|
|
13
|
+
it('closes a successfully opened handle when safety inspection rejects it', () => {
|
|
14
|
+
const authority = uninitializedAuthority();
|
|
15
|
+
const handle = BigInt(42);
|
|
16
|
+
const close = vi.fn<(value: TNativeHandle) => void>();
|
|
17
|
+
Reflect.set(authority, 'issueRelativeOpen', () => ({ handle, status: 0 }));
|
|
18
|
+
Reflect.set(authority, 'assertSafeHandle', () => {
|
|
19
|
+
throw new Error('unsafe handle');
|
|
20
|
+
});
|
|
21
|
+
Reflect.set(authority, 'close', close);
|
|
22
|
+
|
|
23
|
+
expect(() => authority.openRelative(BigInt(1), 'payload.bin', true, 0)).toThrow(
|
|
24
|
+
'unsafe handle',
|
|
25
|
+
);
|
|
26
|
+
expect(close).toHaveBeenCalledExactlyOnceWith(handle);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('closes a returned handle when NtCreateFile reports failure', () => {
|
|
30
|
+
const authority = uninitializedAuthority();
|
|
31
|
+
const handle = BigInt(42);
|
|
32
|
+
const close = vi.fn<(value: TNativeHandle) => void>();
|
|
33
|
+
Reflect.set(authority, 'issueRelativeOpen', () => ({ handle, status: -1 }));
|
|
34
|
+
Reflect.set(authority, 'close', close);
|
|
35
|
+
|
|
36
|
+
expect(() => authority.openRelative(BigInt(1), 'payload.bin', true, 0)).toThrow();
|
|
37
|
+
expect(close).toHaveBeenCalledExactlyOnceWith(handle);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('falls back to CloseHandle when NtClose returns a failure status', () => {
|
|
41
|
+
const authority = uninitializedAuthority();
|
|
42
|
+
const ntClose = vi.fn<() => number>().mockReturnValue(-1);
|
|
43
|
+
const closeHandle = vi.fn<() => boolean>().mockReturnValue(true);
|
|
44
|
+
Reflect.set(authority, 'api', { ntClose, closeHandle });
|
|
45
|
+
|
|
46
|
+
authority.close(BigInt(42));
|
|
47
|
+
|
|
48
|
+
expect(ntClose).toHaveBeenCalledOnce();
|
|
49
|
+
expect(closeHandle).toHaveBeenCalledOnce();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not double-close after NtClose succeeds', () => {
|
|
53
|
+
const authority = uninitializedAuthority();
|
|
54
|
+
const ntClose = vi.fn<() => number>().mockReturnValue(0);
|
|
55
|
+
const closeHandle = vi.fn<() => boolean>().mockReturnValue(true);
|
|
56
|
+
Reflect.set(authority, 'api', { ntClose, closeHandle });
|
|
57
|
+
|
|
58
|
+
authority.close(BigInt(42));
|
|
59
|
+
|
|
60
|
+
expect(ntClose).toHaveBeenCalledOnce();
|
|
61
|
+
expect(closeHandle).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('WindowsNativeAuthority read sharing', () => {
|
|
66
|
+
it('excludes concurrent writers only while a final file is held for reading', () => {
|
|
67
|
+
const authority = uninitializedAuthority();
|
|
68
|
+
const handleType = koffi.pointer('TestStableReadHandle', koffi.opaque());
|
|
69
|
+
const unicodeStringType = koffi.struct('TestStableReadUnicodeString', {
|
|
70
|
+
Length: 'uint16_t',
|
|
71
|
+
MaximumLength: 'uint16_t',
|
|
72
|
+
Buffer: koffi.pointer('char16_t'),
|
|
73
|
+
});
|
|
74
|
+
const objectAttributesType = koffi.struct('TestStableReadObjectAttributes', {
|
|
75
|
+
Length: 'uint32_t',
|
|
76
|
+
RootDirectory: handleType,
|
|
77
|
+
ObjectName: koffi.pointer(unicodeStringType),
|
|
78
|
+
Attributes: 'uint32_t',
|
|
79
|
+
SecurityDescriptor: 'void *',
|
|
80
|
+
SecurityQualityOfService: 'void *',
|
|
81
|
+
});
|
|
82
|
+
const shares: number[] = [];
|
|
83
|
+
Reflect.set(authority, 'api', {
|
|
84
|
+
unicodeStringType,
|
|
85
|
+
objectAttributesType,
|
|
86
|
+
ntCreateFile: (
|
|
87
|
+
output: TNativeHandle[],
|
|
88
|
+
_desiredAccess: number,
|
|
89
|
+
_attributes: object,
|
|
90
|
+
_ioStatus: object,
|
|
91
|
+
_allocationSize: null,
|
|
92
|
+
_fileAttributes: number,
|
|
93
|
+
shareAccess: number,
|
|
94
|
+
) => {
|
|
95
|
+
shares.push(shareAccess);
|
|
96
|
+
output[0] = BigInt(42);
|
|
97
|
+
return 0;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const issueRelativeOpen = Reflect.get(authority, 'issueRelativeOpen') as (
|
|
101
|
+
parent: TNativeHandle,
|
|
102
|
+
segment: string,
|
|
103
|
+
final: boolean,
|
|
104
|
+
) => unknown;
|
|
105
|
+
|
|
106
|
+
issueRelativeOpen.call(authority, BigInt(1), 'nested', false);
|
|
107
|
+
issueRelativeOpen.call(authority, BigInt(1), 'payload.bin', true);
|
|
108
|
+
|
|
109
|
+
expect(shares).toHaveLength(2);
|
|
110
|
+
expect(shares[0] & 0x2).toBe(0x2);
|
|
111
|
+
expect(shares[1] & 0x2).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type TStableFileAuthorityErrorCode =
|
|
2
|
+
| 'INVALID_PATH'
|
|
3
|
+
| 'UNSAFE_ENTRY'
|
|
4
|
+
| 'UNSUPPORTED_BACKEND'
|
|
5
|
+
| 'AUTHORITY_CLOSED'
|
|
6
|
+
| 'ROOT_CHANGED'
|
|
7
|
+
| 'FILE_CHANGED'
|
|
8
|
+
| 'OVER_BUDGET'
|
|
9
|
+
| 'HOST_IO';
|
|
10
|
+
|
|
11
|
+
export interface IStableFileAuthorityErrorContext {
|
|
12
|
+
readonly operation: string;
|
|
13
|
+
readonly segmentIndex?: number;
|
|
14
|
+
readonly hostCode?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ERROR_MESSAGES: Readonly<Record<TStableFileAuthorityErrorCode, string>> = {
|
|
18
|
+
INVALID_PATH: 'The requested root-relative file operation is invalid.',
|
|
19
|
+
UNSAFE_ENTRY: 'The requested entry cannot be opened without following an unsafe object.',
|
|
20
|
+
UNSUPPORTED_BACKEND: 'This host cannot provide stable root-relative file reads.',
|
|
21
|
+
AUTHORITY_CLOSED: 'The stable rooted file authority is closed.',
|
|
22
|
+
ROOT_CHANGED: 'The retained root identity changed during the operation.',
|
|
23
|
+
FILE_CHANGED: 'The file identity or contents changed during the bounded read.',
|
|
24
|
+
OVER_BUDGET: 'The file exceeds the supplied byte budget.',
|
|
25
|
+
HOST_IO: 'The host could not complete the stable root-relative file operation.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A stable, secret-free failure from a rooted file authority. */
|
|
29
|
+
export class StableFileAuthorityError extends Error {
|
|
30
|
+
readonly code: TStableFileAuthorityErrorCode;
|
|
31
|
+
readonly context: Readonly<IStableFileAuthorityErrorContext>;
|
|
32
|
+
|
|
33
|
+
constructor(code: TStableFileAuthorityErrorCode, context: IStableFileAuthorityErrorContext) {
|
|
34
|
+
super(ERROR_MESSAGES[code]);
|
|
35
|
+
this.name = 'StableFileAuthorityError';
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.context = Object.freeze({ ...context });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Opaque authority for bounded regular-file reads beneath one retained directory identity. */
|
|
42
|
+
export interface IStableRootedFileReader {
|
|
43
|
+
readBytes(relativeSegments: readonly string[], maxBytes: number): Uint8Array | undefined;
|
|
44
|
+
close(): void;
|
|
45
|
+
[Symbol.dispose](): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function authorityError(
|
|
49
|
+
code: TStableFileAuthorityErrorCode,
|
|
50
|
+
operation: string,
|
|
51
|
+
details: Omit<IStableFileAuthorityErrorContext, 'operation'> = {},
|
|
52
|
+
): StableFileAuthorityError {
|
|
53
|
+
return new StableFileAuthorityError(code, { operation, ...details });
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface IFileAuthorityTestHooks {
|
|
2
|
+
readonly afterDirectoryOpened?: (segmentIndex: number) => void;
|
|
3
|
+
readonly afterReadChunk?: (bytesRead: number) => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface IStableFileHostBackend {
|
|
7
|
+
readBytes(
|
|
8
|
+
relativeSegments: readonly string[],
|
|
9
|
+
maxBytes: number,
|
|
10
|
+
hooks: IFileAuthorityTestHooks,
|
|
11
|
+
): Uint8Array | undefined;
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, readSync, type BigIntStats } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import * as koffi from 'koffi';
|
|
4
|
+
|
|
5
|
+
import { authorityError, StableFileAuthorityError } from './contracts.js';
|
|
6
|
+
|
|
7
|
+
import type { IFileAuthorityTestHooks, IStableFileHostBackend } from './host-backend.js';
|
|
8
|
+
|
|
9
|
+
interface IPosixIdentity {
|
|
10
|
+
readonly device: bigint;
|
|
11
|
+
readonly inode: bigint;
|
|
12
|
+
readonly mode: bigint;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface IPosixFileSnapshot extends IPosixIdentity {
|
|
16
|
+
readonly size: bigint;
|
|
17
|
+
readonly modified: bigint;
|
|
18
|
+
readonly changed: bigint;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type TOpen = (path: string, flags: number) => number;
|
|
22
|
+
type TOpenAt = (directory: number, path: string, flags: number) => number;
|
|
23
|
+
|
|
24
|
+
const CHUNK_BYTES = 1_048_576;
|
|
25
|
+
const DARWIN_CLOSE_ON_EXEC = 0x01000000;
|
|
26
|
+
const LINUX_CLOSE_ON_EXEC = 0x00080000;
|
|
27
|
+
|
|
28
|
+
function identity(metadata: BigIntStats): IPosixIdentity {
|
|
29
|
+
return { device: metadata.dev, inode: metadata.ino, mode: metadata.mode };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function snapshot(metadata: BigIntStats): IPosixFileSnapshot {
|
|
33
|
+
return {
|
|
34
|
+
...identity(metadata),
|
|
35
|
+
size: metadata.size,
|
|
36
|
+
modified: metadata.mtimeNs,
|
|
37
|
+
changed: metadata.ctimeNs,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sameIdentity(left: IPosixIdentity, right: IPosixIdentity): boolean {
|
|
42
|
+
return left.device === right.device && left.inode === right.inode && left.mode === right.mode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sameSnapshot(left: IPosixFileSnapshot, right: IPosixFileSnapshot): boolean {
|
|
46
|
+
return (
|
|
47
|
+
sameIdentity(left, right) &&
|
|
48
|
+
left.size === right.size &&
|
|
49
|
+
left.modified === right.modified &&
|
|
50
|
+
left.changed === right.changed
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hostCode(errno: number): string {
|
|
55
|
+
for (const [name, value] of Object.entries(koffi.os.errno)) {
|
|
56
|
+
if (value === errno) return name;
|
|
57
|
+
}
|
|
58
|
+
return `ERRNO_${errno}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function flags(directory: boolean): number {
|
|
62
|
+
const closeOnExec =
|
|
63
|
+
(constants as typeof constants & { readonly O_CLOEXEC?: number }).O_CLOEXEC ??
|
|
64
|
+
(process.platform === 'darwin' ? DARWIN_CLOSE_ON_EXEC : LINUX_CLOSE_ON_EXEC);
|
|
65
|
+
return (
|
|
66
|
+
constants.O_RDONLY |
|
|
67
|
+
constants.O_NOFOLLOW |
|
|
68
|
+
constants.O_NONBLOCK |
|
|
69
|
+
closeOnExec |
|
|
70
|
+
(directory ? constants.O_DIRECTORY : 0)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeFstat(descriptor: number, operation: string): BigIntStats {
|
|
75
|
+
try {
|
|
76
|
+
return fstatSync(descriptor, { bigint: true });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const code = nodeErrorCode(typeof error === 'object' ? error : null);
|
|
79
|
+
throw authorityError('HOST_IO', operation, code === undefined ? {} : { hostCode: code });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function nodeErrorCode(error: object | null): string | undefined {
|
|
84
|
+
if (error === null || !('code' in error)) return undefined;
|
|
85
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** POSIX implementation; descriptors never leave this module. */
|
|
89
|
+
export class PosixStableFileHostBackend implements IStableFileHostBackend {
|
|
90
|
+
private readonly openAt: TOpenAt;
|
|
91
|
+
private readonly rootIdentity: IPosixIdentity;
|
|
92
|
+
private rootDescriptor: number | undefined;
|
|
93
|
+
|
|
94
|
+
constructor(rootDirectory: string) {
|
|
95
|
+
try {
|
|
96
|
+
const libc = koffi.load(
|
|
97
|
+
process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6',
|
|
98
|
+
);
|
|
99
|
+
const open = libc.func('int open(const char *path, int flags)') as TOpen;
|
|
100
|
+
this.openAt = libc.func('int openat(int fd, const char *path, int flags)') as TOpenAt;
|
|
101
|
+
const descriptor = open(rootDirectory, flags(true));
|
|
102
|
+
if (descriptor < 0) this.throwOpenFailure(koffi.errno(), 'open-root');
|
|
103
|
+
this.rootDescriptor = descriptor;
|
|
104
|
+
const metadata = safeFstat(descriptor, 'inspect-root');
|
|
105
|
+
if (!metadata.isDirectory()) throw authorityError('UNSAFE_ENTRY', 'open-root');
|
|
106
|
+
this.rootIdentity = identity(metadata);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (this.rootDescriptor !== undefined) {
|
|
109
|
+
this.closeDescriptor(this.rootDescriptor);
|
|
110
|
+
this.rootDescriptor = undefined;
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
113
|
+
throw authorityError('UNSUPPORTED_BACKEND', 'load-posix-backend');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
readBytes(
|
|
118
|
+
relativeSegments: readonly string[],
|
|
119
|
+
maxBytes: number,
|
|
120
|
+
hooks: IFileAuthorityTestHooks,
|
|
121
|
+
): Uint8Array | undefined {
|
|
122
|
+
const rootDescriptor = this.rootDescriptor;
|
|
123
|
+
if (rootDescriptor === undefined) throw authorityError('AUTHORITY_CLOSED', 'read');
|
|
124
|
+
this.assertRootIdentity(rootDescriptor);
|
|
125
|
+
|
|
126
|
+
let parentDescriptor = rootDescriptor;
|
|
127
|
+
let ownsParent = false;
|
|
128
|
+
let fileDescriptor: number | undefined;
|
|
129
|
+
try {
|
|
130
|
+
for (let index = 0; index < relativeSegments.length; index += 1) {
|
|
131
|
+
const final = index === relativeSegments.length - 1;
|
|
132
|
+
const descriptor = this.openAt(parentDescriptor, relativeSegments[index], flags(!final));
|
|
133
|
+
if (descriptor < 0) {
|
|
134
|
+
const errno = koffi.errno();
|
|
135
|
+
if (errno === koffi.os.errno.ENOENT) return undefined;
|
|
136
|
+
this.throwOpenFailure(errno, final ? 'open-file' : 'open-directory', index);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (final) {
|
|
140
|
+
fileDescriptor = descriptor;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const metadata = safeFstat(descriptor, 'inspect-directory');
|
|
145
|
+
if (!metadata.isDirectory()) {
|
|
146
|
+
this.closeDescriptor(descriptor);
|
|
147
|
+
throw authorityError('UNSAFE_ENTRY', 'inspect-directory', { segmentIndex: index });
|
|
148
|
+
}
|
|
149
|
+
if (ownsParent) this.closeDescriptor(parentDescriptor);
|
|
150
|
+
parentDescriptor = descriptor;
|
|
151
|
+
ownsParent = true;
|
|
152
|
+
hooks.afterDirectoryOpened?.(index);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (fileDescriptor === undefined) throw authorityError('HOST_IO', 'open-file');
|
|
156
|
+
const bytes = this.readFile(fileDescriptor, maxBytes, hooks);
|
|
157
|
+
this.assertRootIdentity(rootDescriptor);
|
|
158
|
+
return bytes;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
161
|
+
const code = nodeErrorCode(typeof error === 'object' ? error : null);
|
|
162
|
+
throw authorityError('HOST_IO', 'read', code === undefined ? {} : { hostCode: code });
|
|
163
|
+
} finally {
|
|
164
|
+
this.closeOptionalDescriptor(fileDescriptor);
|
|
165
|
+
this.closeOptionalDescriptor(ownsParent ? parentDescriptor : undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
const descriptor = this.rootDescriptor;
|
|
171
|
+
if (descriptor === undefined) return;
|
|
172
|
+
this.rootDescriptor = undefined;
|
|
173
|
+
this.closeDescriptor(descriptor);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private readFile(
|
|
177
|
+
descriptor: number,
|
|
178
|
+
maxBytes: number,
|
|
179
|
+
hooks: IFileAuthorityTestHooks,
|
|
180
|
+
): Uint8Array {
|
|
181
|
+
const beforeMetadata = safeFstat(descriptor, 'inspect-file');
|
|
182
|
+
if (!beforeMetadata.isFile()) throw authorityError('UNSAFE_ENTRY', 'inspect-file');
|
|
183
|
+
if (beforeMetadata.size > BigInt(maxBytes)) throw authorityError('OVER_BUDGET', 'read-file');
|
|
184
|
+
|
|
185
|
+
const expectedBytes = Number(beforeMetadata.size);
|
|
186
|
+
let bytes: Buffer;
|
|
187
|
+
try {
|
|
188
|
+
bytes = Buffer.alloc(expectedBytes);
|
|
189
|
+
} catch {
|
|
190
|
+
throw authorityError('HOST_IO', 'allocate-read-buffer');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let offset = 0;
|
|
194
|
+
while (offset < expectedBytes) {
|
|
195
|
+
const count = readSync(
|
|
196
|
+
descriptor,
|
|
197
|
+
bytes,
|
|
198
|
+
offset,
|
|
199
|
+
Math.min(CHUNK_BYTES, expectedBytes - offset),
|
|
200
|
+
offset,
|
|
201
|
+
);
|
|
202
|
+
if (count === 0) break;
|
|
203
|
+
offset += count;
|
|
204
|
+
hooks.afterReadChunk?.(offset);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const probe = Buffer.alloc(1);
|
|
208
|
+
const grew = readSync(descriptor, probe, 0, 1, expectedBytes) !== 0;
|
|
209
|
+
const afterMetadata = safeFstat(descriptor, 'reinspect-file');
|
|
210
|
+
if (grew && expectedBytes >= maxBytes) throw authorityError('OVER_BUDGET', 'read-file');
|
|
211
|
+
if (
|
|
212
|
+
offset !== expectedBytes ||
|
|
213
|
+
grew ||
|
|
214
|
+
!sameSnapshot(snapshot(beforeMetadata), snapshot(afterMetadata))
|
|
215
|
+
) {
|
|
216
|
+
throw authorityError('FILE_CHANGED', 'read-file');
|
|
217
|
+
}
|
|
218
|
+
return bytes;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private assertRootIdentity(descriptor: number): void {
|
|
222
|
+
const metadata = safeFstat(descriptor, 'inspect-root');
|
|
223
|
+
if (!metadata.isDirectory() || !sameIdentity(this.rootIdentity, identity(metadata))) {
|
|
224
|
+
throw authorityError('ROOT_CHANGED', 'inspect-root');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private throwOpenFailure(errno: number, operation: string, segmentIndex?: number): never {
|
|
229
|
+
const code = hostCode(errno);
|
|
230
|
+
const details =
|
|
231
|
+
segmentIndex === undefined ? { hostCode: code } : { hostCode: code, segmentIndex };
|
|
232
|
+
if (
|
|
233
|
+
errno === koffi.os.errno.ELOOP ||
|
|
234
|
+
errno === koffi.os.errno.ENOTDIR ||
|
|
235
|
+
errno === koffi.os.errno.EISDIR
|
|
236
|
+
) {
|
|
237
|
+
throw authorityError('UNSAFE_ENTRY', operation, details);
|
|
238
|
+
}
|
|
239
|
+
throw authorityError('HOST_IO', operation, details);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private closeDescriptor(descriptor: number): void {
|
|
243
|
+
try {
|
|
244
|
+
closeSync(descriptor);
|
|
245
|
+
} catch {
|
|
246
|
+
// allow-fallback: descriptor close failures cannot restore authority or make a later read safe
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private closeOptionalDescriptor(descriptor: number | undefined): void {
|
|
251
|
+
if (descriptor !== undefined) this.closeDescriptor(descriptor);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
authorityError,
|
|
5
|
+
StableFileAuthorityError,
|
|
6
|
+
type IStableRootedFileReader,
|
|
7
|
+
} from './contracts.js';
|
|
8
|
+
import { PosixStableFileHostBackend } from './posix-backend.js';
|
|
9
|
+
import { WindowsStableFileHostBackend } from './windows-backend.js';
|
|
10
|
+
|
|
11
|
+
import type { IFileAuthorityTestHooks, IStableFileHostBackend } from './host-backend.js';
|
|
12
|
+
|
|
13
|
+
export type { IFileAuthorityTestHooks } from './host-backend.js';
|
|
14
|
+
|
|
15
|
+
const SUPPORTED_HOSTS = new Set([
|
|
16
|
+
'darwin-arm64',
|
|
17
|
+
'darwin-x64',
|
|
18
|
+
'linux-arm64',
|
|
19
|
+
'linux-x64',
|
|
20
|
+
'win32-x64',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const finalizer = new FinalizationRegistry<IStableFileHostBackend>((backend) => {
|
|
24
|
+
backend.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function validateRoot(rootDirectory: string): string {
|
|
28
|
+
if (
|
|
29
|
+
typeof rootDirectory !== 'string' ||
|
|
30
|
+
rootDirectory.trim().length === 0 ||
|
|
31
|
+
rootDirectory.includes('\0')
|
|
32
|
+
) {
|
|
33
|
+
throw authorityError('INVALID_PATH', 'validate-root');
|
|
34
|
+
}
|
|
35
|
+
return resolve(rootDirectory);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateReadInput(relativeSegments: readonly string[], maxBytes: number): void {
|
|
39
|
+
if (!Array.isArray(relativeSegments) || relativeSegments.length === 0) {
|
|
40
|
+
throw authorityError('INVALID_PATH', 'validate-segments');
|
|
41
|
+
}
|
|
42
|
+
for (let index = 0; index < relativeSegments.length; index += 1) {
|
|
43
|
+
const segment = relativeSegments[index];
|
|
44
|
+
if (
|
|
45
|
+
typeof segment !== 'string' ||
|
|
46
|
+
segment.length === 0 ||
|
|
47
|
+
segment === '.' ||
|
|
48
|
+
segment === '..' ||
|
|
49
|
+
segment.includes('\0') ||
|
|
50
|
+
segment.includes('/') ||
|
|
51
|
+
segment.includes('\\')
|
|
52
|
+
) {
|
|
53
|
+
throw authorityError('INVALID_PATH', 'validate-segments', { segmentIndex: index });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
57
|
+
throw authorityError('INVALID_PATH', 'validate-budget');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createHostBackend(rootDirectory: string): IStableFileHostBackend {
|
|
62
|
+
const host = `${process.platform}-${process.arch}`;
|
|
63
|
+
if (!SUPPORTED_HOSTS.has(host)) {
|
|
64
|
+
throw authorityError('UNSUPPORTED_BACKEND', 'select-backend', { hostCode: host });
|
|
65
|
+
}
|
|
66
|
+
if (process.platform === 'win32') return new WindowsStableFileHostBackend(rootDirectory);
|
|
67
|
+
return new PosixStableFileHostBackend(rootDirectory);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class StableRootedFileReader implements IStableRootedFileReader {
|
|
71
|
+
private backend: IStableFileHostBackend | undefined;
|
|
72
|
+
private readonly finalizerToken = {};
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
rootDirectory: string,
|
|
76
|
+
private readonly hooks: IFileAuthorityTestHooks,
|
|
77
|
+
) {
|
|
78
|
+
const backend = createHostBackend(validateRoot(rootDirectory));
|
|
79
|
+
this.backend = backend;
|
|
80
|
+
finalizer.register(this, backend, this.finalizerToken);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
readBytes(relativeSegments: readonly string[], maxBytes: number): Uint8Array | undefined {
|
|
84
|
+
const backend = this.backend;
|
|
85
|
+
if (backend === undefined) throw authorityError('AUTHORITY_CLOSED', 'read');
|
|
86
|
+
validateReadInput(relativeSegments, maxBytes);
|
|
87
|
+
try {
|
|
88
|
+
return backend.readBytes(relativeSegments, maxBytes, this.hooks);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
91
|
+
throw authorityError('HOST_IO', 'read');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close(): void {
|
|
96
|
+
const backend = this.backend;
|
|
97
|
+
if (backend === undefined) return;
|
|
98
|
+
this.backend = undefined;
|
|
99
|
+
finalizer.unregister(this.finalizerToken);
|
|
100
|
+
try {
|
|
101
|
+
backend.close();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
104
|
+
throw authorityError('HOST_IO', 'close');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
[Symbol.dispose](): void {
|
|
109
|
+
this.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Create a stable, opaque read authority rooted at the currently opened directory identity. */
|
|
114
|
+
export function createStableRootedFileReader(rootDirectory: string): IStableRootedFileReader {
|
|
115
|
+
return new StableRootedFileReader(rootDirectory, {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Internal-only deterministic seams used by native replacement tests. */
|
|
119
|
+
export function createStableRootedFileReaderForTest(
|
|
120
|
+
rootDirectory: string,
|
|
121
|
+
hooks: IFileAuthorityTestHooks,
|
|
122
|
+
): IStableRootedFileReader {
|
|
123
|
+
return new StableRootedFileReader(rootDirectory, hooks);
|
|
124
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { authorityError, StableFileAuthorityError } from './contracts.js';
|
|
2
|
+
import { WindowsNativeAuthority, type IWindowsIdentity } from './windows-native-authority.js';
|
|
3
|
+
|
|
4
|
+
import type { IFileAuthorityTestHooks, IStableFileHostBackend } from './host-backend.js';
|
|
5
|
+
import type { TNativeHandle } from './windows-native-api.js';
|
|
6
|
+
|
|
7
|
+
/** Windows implementation; native HANDLE values never leave the native authority module. */
|
|
8
|
+
export class WindowsStableFileHostBackend implements IStableFileHostBackend {
|
|
9
|
+
private readonly native = new WindowsNativeAuthority();
|
|
10
|
+
private readonly rootIdentity: IWindowsIdentity;
|
|
11
|
+
private rootHandle: TNativeHandle | undefined;
|
|
12
|
+
|
|
13
|
+
constructor(rootDirectory: string) {
|
|
14
|
+
try {
|
|
15
|
+
const handle = this.native.openRoot(rootDirectory);
|
|
16
|
+
this.rootHandle = handle;
|
|
17
|
+
this.rootIdentity = this.native.queryIdentity(handle, 'inspect-root');
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (this.rootHandle !== undefined) {
|
|
20
|
+
this.native.close(this.rootHandle);
|
|
21
|
+
this.rootHandle = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
24
|
+
throw authorityError('UNSUPPORTED_BACKEND', 'load-windows-backend');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
readBytes(
|
|
29
|
+
relativeSegments: readonly string[],
|
|
30
|
+
maxBytes: number,
|
|
31
|
+
hooks: IFileAuthorityTestHooks,
|
|
32
|
+
): Uint8Array | undefined {
|
|
33
|
+
const rootHandle = this.rootHandle;
|
|
34
|
+
if (rootHandle === undefined) throw authorityError('AUTHORITY_CLOSED', 'read');
|
|
35
|
+
this.assertRootIdentity(rootHandle);
|
|
36
|
+
|
|
37
|
+
let parentHandle = rootHandle;
|
|
38
|
+
let ownsParent = false;
|
|
39
|
+
let fileHandle: TNativeHandle | undefined;
|
|
40
|
+
try {
|
|
41
|
+
for (let index = 0; index < relativeSegments.length; index += 1) {
|
|
42
|
+
const final = index === relativeSegments.length - 1;
|
|
43
|
+
const handle = this.native.openRelative(
|
|
44
|
+
parentHandle,
|
|
45
|
+
relativeSegments[index],
|
|
46
|
+
final,
|
|
47
|
+
index,
|
|
48
|
+
);
|
|
49
|
+
if (handle === undefined) return undefined;
|
|
50
|
+
if (final) {
|
|
51
|
+
fileHandle = handle;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (ownsParent) this.native.close(parentHandle);
|
|
55
|
+
parentHandle = handle;
|
|
56
|
+
ownsParent = true;
|
|
57
|
+
hooks.afterDirectoryOpened?.(index);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fileHandle === undefined) throw authorityError('HOST_IO', 'open-file');
|
|
61
|
+
const bytes = this.native.readFile(fileHandle, maxBytes, hooks);
|
|
62
|
+
this.assertRootIdentity(rootHandle);
|
|
63
|
+
return bytes;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof StableFileAuthorityError) throw error;
|
|
66
|
+
throw authorityError('HOST_IO', 'read');
|
|
67
|
+
} finally {
|
|
68
|
+
if (fileHandle !== undefined) this.native.close(fileHandle);
|
|
69
|
+
if (ownsParent) this.native.close(parentHandle);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
close(): void {
|
|
74
|
+
const handle = this.rootHandle;
|
|
75
|
+
if (handle === undefined) return;
|
|
76
|
+
this.rootHandle = undefined;
|
|
77
|
+
this.native.close(handle);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private assertRootIdentity(handle: TNativeHandle): void {
|
|
81
|
+
const current = this.native.queryIdentity(handle, 'inspect-root');
|
|
82
|
+
if (
|
|
83
|
+
current.volume !== this.rootIdentity.volume ||
|
|
84
|
+
current.fileId !== this.rootIdentity.fileId
|
|
85
|
+
) {
|
|
86
|
+
throw authorityError('ROOT_CHANGED', 'inspect-root');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|