javi-forge 1.30.0 → 1.31.0

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.
@@ -0,0 +1,1223 @@
1
+ # javi-forge Windows secure-object helper (SkillGuard Slice 3b, Phase 3).
2
+ #
3
+ # This is the ONLY place a Windows security decision is made. It is a long-lived,
4
+ # framed-stdin session process spawned by src/lib/secure-fs-windows.ts
5
+ # (createPs1Session) after a sha256 digest match against manifest.json. It owns
6
+ # the real OS handles (a handleId -> SafeHandle table for the parent-chain lock)
7
+ # and computes Predicate A (lenient runtime gate) / Predicate B (strict creation)
8
+ # / proveManagedContainer (CREATE_PARENT_DIR) verdicts on FRESH no-follow kernel
9
+ # handles per call.
10
+ #
11
+ # CANNOT be run or validated on the Linux dev box. Correctness rests on:
12
+ # (a) faithful adherence to design.md (Decisions 1/1a/1b/2/3, Predicate A/B),
13
+ # (b) EXACT protocol match with secure-fs-windows.ts (HelperOp/HelperRequest/
14
+ # HelperResponse shapes; the framed [uint32 BE length][UTF-8 JSON] wire),
15
+ # (c) Windows PowerShell 5.1 syntax discipline + PURE ASCII.
16
+ # The Phase 5 windows-latest CI job is the ONLY validator (design Decision 3).
17
+ #
18
+ # Grounding-probe lessons applied (scripts/win-acl-probe.ps1 ran on windows-latest):
19
+ # - Pure ASCII only: a stray em-dash broke PS 5.1 parsing. No non-ASCII bytes.
20
+ # - Enum bit-tests via [int] casts: a bare enum -band threw InvalidCastException.
21
+ # All flag/mask math below is done in C# on int/uint, cast explicitly.
22
+ #
23
+ # Protocol (secure-fs-windows.ts):
24
+ # Wire unit : [uint32 big-endian byteLength][UTF-8 JSON body], both directions.
25
+ # Handshake : first frame emitted = {"ready":true,"protocolVersion":1}.
26
+ # Serial : exactly one outstanding request; response before the next request.
27
+ # Bounded : reject any declared length > HELPER_FRAME_LIMIT (8 MiB).
28
+ # stdout : ONLY length-prefixed frames. All diagnostics go to stderr.
29
+ # ops : openDir revalidate proveOwner proveDacl proveContainer createDir
30
+ # capture writeExcl applyMode rename unlink rmdir releaseHandle.
31
+
32
+ $ErrorActionPreference = 'Stop'
33
+ $ProgressPreference = 'SilentlyContinue'
34
+
35
+ $script:FRAME_LIMIT = 8 * 1024 * 1024
36
+ $script:PROTOCOL_VERSION = 1
37
+
38
+ # --- native + predicate core (C#: int/uint mask math, no PS enum -band) -------
39
+
40
+ $csharp = @'
41
+ using System;
42
+ using System.IO;
43
+ using System.Collections.Generic;
44
+ using System.Runtime.InteropServices;
45
+ using System.Security.AccessControl;
46
+ using System.Security.Principal;
47
+ using Microsoft.Win32.SafeHandles;
48
+
49
+ namespace JaviForge
50
+ {
51
+ public class OpResult
52
+ {
53
+ public bool Ok;
54
+ public string Refusal;
55
+ public string Detail;
56
+ public int Status; // win32 error code; drives the notFound mapping on openDir
57
+ public string HandleId; // openDir / createDir
58
+ public string Opaque; // openDir / createDir / capture
59
+ public int Attributes; // openDir / createDir (dwFileAttributes)
60
+ public string BytesB64; // capture
61
+
62
+ public static OpResult Good() { OpResult r = new OpResult(); r.Ok = true; return r; }
63
+ public static OpResult Fail(string refusal, string detail)
64
+ {
65
+ OpResult r = new OpResult();
66
+ r.Ok = false; r.Refusal = refusal; r.Detail = detail; return r;
67
+ }
68
+ public static OpResult FailStatus(string refusal, string detail, int status)
69
+ {
70
+ OpResult r = Fail(refusal, detail); r.Status = status; return r;
71
+ }
72
+ }
73
+
74
+ internal class Held
75
+ {
76
+ public IntPtr Handle;
77
+ public string Path;
78
+ public string Opaque;
79
+ }
80
+
81
+ public static class SecureObj
82
+ {
83
+ // --- refusal classes (must match SecureRefusal in secure-fs-transaction.ts) -
84
+ private const string R_DACL = "unsafe-windows-dacl";
85
+ private const string R_CHAIN = "unsafe-parent-chain";
86
+
87
+ // --- access-right / attribute constants ---------------------------------
88
+ private const uint GENERIC_READ = 0x80000000;
89
+ private const uint GENERIC_WRITE = 0x40000000;
90
+ private const uint READ_CONTROL = 0x00020000;
91
+ private const uint WRITE_DAC_A = 0x00040000;
92
+ private const uint WRITE_OWNER_A = 0x00080000;
93
+ private const uint DELETE_A = 0x00010000;
94
+ private const uint FILE_READ_ATTRIBUTES = 0x0080;
95
+
96
+ private const uint FILE_SHARE_READ = 0x1;
97
+ private const uint FILE_SHARE_WRITE = 0x2;
98
+ private const uint FILE_SHARE_DELETE = 0x4;
99
+
100
+ private const uint OPEN_EXISTING = 3;
101
+ private const uint CREATE_NEW = 1;
102
+
103
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
104
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
105
+ private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
106
+
107
+ private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
108
+ private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400;
109
+
110
+ private const int ERROR_FILE_NOT_FOUND = 2;
111
+ private const int ERROR_PATH_NOT_FOUND = 3;
112
+ private const int ERROR_FILE_EXISTS = 80;
113
+ private const int ERROR_ALREADY_EXISTS = 183;
114
+ private const int ERROR_DIR_NOT_EMPTY = 145;
115
+
116
+ private const uint MOVEFILE_REPLACE_EXISTING = 0x1;
117
+ private const uint MOVEFILE_WRITE_THROUGH = 0x8;
118
+
119
+ // GetSecurityInfo / SetKernelObjectSecurity information classes
120
+ private const uint OWNER_SECURITY_INFORMATION = 0x1;
121
+ private const uint DACL_SECURITY_INFORMATION = 0x4;
122
+ private const uint PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000;
123
+ private const int SE_FILE_OBJECT = 1;
124
+
125
+ // FILE_INFO_BY_HANDLE_CLASS.FileDispositionInfo
126
+ private const int FileDispositionInfo = 4;
127
+
128
+ // --- Predicate A / B / container path-endangering masks (design.md) ------
129
+ // FILE_WRITE_DATA / FILE_ADD_FILE = 0x0002
130
+ // FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY = 0x0004
131
+ // FILE_DELETE_CHILD = 0x0040
132
+ // DELETE = 0x00010000 WRITE_DAC = 0x00040000 WRITE_OWNER = 0x00080000
133
+ private const int ADD_FILE = 0x0002;
134
+ private const int ADD_SUBDIR = 0x0004;
135
+ private const int DELETE_CHILD = 0x0040;
136
+ // File-object write bits (same numeric values as ADD_FILE/ADD_SUBDIR on a
137
+ // container, but named for the file mask so PATH_ENDANGER_FILE is self-describing).
138
+ private const int FILE_WRITE_DATA = 0x0002;
139
+ private const int FILE_APPEND_DATA = 0x0004;
140
+ private const int MASK_DELETE = 0x00010000;
141
+ private const int MASK_WRITE_DAC = 0x00040000;
142
+ private const int MASK_WRITE_OWNER = 0x00080000;
143
+ private const int PATH_ENDANGER_COMMON = MASK_DELETE | MASK_WRITE_DAC | MASK_WRITE_OWNER;
144
+ private const int PATH_ENDANGER_DIR = PATH_ENDANGER_COMMON | DELETE_CHILD;
145
+ private const int PATH_ENDANGER_FILE = PATH_ENDANGER_COMMON | FILE_WRITE_DATA | FILE_APPEND_DATA;
146
+ private const int CREATE_PARENT_DIR = PATH_ENDANGER_DIR | ADD_FILE | ADD_SUBDIR;
147
+
148
+ private const int FILE_ALL_ACCESS = 0x001F01FF;
149
+
150
+ // NT SERVICE\TrustedInstaller (trusted OWNER only; JDB-204 / F4a).
151
+ private const string SID_TRUSTED_INSTALLER =
152
+ "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
153
+
154
+ private static readonly object Gate = new object();
155
+ private static readonly Dictionary<string, Held> Handles = new Dictionary<string, Held>();
156
+ private static long HandleSeq = 0;
157
+
158
+ // --- P/Invoke -----------------------------------------------------------
159
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
160
+ private static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess,
161
+ uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition,
162
+ uint dwFlagsAndAttributes, IntPtr hTemplateFile);
163
+
164
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
165
+ private static extern bool CreateDirectoryW(string lpPathName, IntPtr lpSecurityAttributes);
166
+
167
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
168
+ private static extern bool MoveFileExW(string from, string to, uint flags);
169
+
170
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
171
+ private static extern bool RemoveDirectoryW(string lpPathName);
172
+
173
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
174
+ private static extern IntPtr FindFirstFileW(string lpFileName,
175
+ out WIN32_FIND_DATA lpFindFileData);
176
+
177
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
178
+ private static extern bool FindNextFileW(IntPtr hFindFile,
179
+ out WIN32_FIND_DATA lpFindFileData);
180
+
181
+ [DllImport("kernel32.dll", SetLastError = true)]
182
+ private static extern bool FindClose(IntPtr hFindFile);
183
+
184
+ [DllImport("kernel32.dll", SetLastError = true)]
185
+ private static extern bool CloseHandle(IntPtr hObject);
186
+
187
+ [DllImport("kernel32.dll", SetLastError = true)]
188
+ private static extern bool FlushFileBuffers(IntPtr hFile);
189
+
190
+ [DllImport("kernel32.dll", SetLastError = true)]
191
+ private static extern bool GetFileInformationByHandle(IntPtr hFile,
192
+ out BY_HANDLE_FILE_INFORMATION lpFileInformation);
193
+
194
+ [DllImport("kernel32.dll", SetLastError = true)]
195
+ private static extern bool SetFileInformationByHandle(IntPtr hFile, int cls,
196
+ ref FILE_DISPOSITION_INFO info, int dwBufferSize);
197
+
198
+ [DllImport("advapi32.dll", SetLastError = true)]
199
+ private static extern uint GetSecurityInfo(IntPtr handle, int ObjectType,
200
+ uint SecurityInfo, out IntPtr ppsidOwner, out IntPtr ppsidGroup,
201
+ out IntPtr ppDacl, out IntPtr ppSacl, out IntPtr ppSecurityDescriptor);
202
+
203
+ [DllImport("advapi32.dll", SetLastError = true)]
204
+ private static extern bool SetKernelObjectSecurity(IntPtr Handle,
205
+ uint SecurityInformation, byte[] SecurityDescriptor);
206
+
207
+ [DllImport("advapi32.dll", SetLastError = true)]
208
+ private static extern uint GetSecurityDescriptorLength(IntPtr pSecurityDescriptor);
209
+
210
+ [DllImport("advapi32.dll", SetLastError = true)]
211
+ private static extern void MapGenericMask(ref uint AccessMask, ref GENERIC_MAPPING map);
212
+
213
+ [DllImport("kernel32.dll")]
214
+ private static extern IntPtr LocalFree(IntPtr hMem);
215
+
216
+ [StructLayout(LayoutKind.Sequential)]
217
+ private struct FILETIME_S { public uint Low; public uint High; }
218
+
219
+ [StructLayout(LayoutKind.Sequential)]
220
+ private struct BY_HANDLE_FILE_INFORMATION
221
+ {
222
+ public uint dwFileAttributes;
223
+ public FILETIME_S ftCreationTime;
224
+ public FILETIME_S ftLastAccessTime;
225
+ public FILETIME_S ftLastWriteTime;
226
+ public uint dwVolumeSerialNumber;
227
+ public uint nFileSizeHigh;
228
+ public uint nFileSizeLow;
229
+ public uint nNumberOfLinks;
230
+ public uint nFileIndexHigh;
231
+ public uint nFileIndexLow;
232
+ }
233
+
234
+ [StructLayout(LayoutKind.Sequential)]
235
+ private struct GENERIC_MAPPING
236
+ {
237
+ public uint GenericRead;
238
+ public uint GenericWrite;
239
+ public uint GenericExecute;
240
+ public uint GenericAll;
241
+ }
242
+
243
+ [StructLayout(LayoutKind.Sequential)]
244
+ private struct SECURITY_ATTRIBUTES
245
+ {
246
+ public int nLength;
247
+ public IntPtr lpSecurityDescriptor;
248
+ public int bInheritHandle;
249
+ }
250
+
251
+ [StructLayout(LayoutKind.Sequential)]
252
+ private struct FILE_DISPOSITION_INFO { public int DeleteFile; }
253
+
254
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
255
+ private struct WIN32_FIND_DATA
256
+ {
257
+ public uint dwFileAttributes;
258
+ public FILETIME_S ftCreationTime;
259
+ public FILETIME_S ftLastAccessTime;
260
+ public FILETIME_S ftLastWriteTime;
261
+ public uint nFileSizeHigh;
262
+ public uint nFileSizeLow;
263
+ public uint dwReserved0;
264
+ public uint dwReserved1;
265
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
266
+ public string cFileName;
267
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
268
+ public string cAlternateFileName;
269
+ }
270
+
271
+ private static GENERIC_MAPPING FileMapping()
272
+ {
273
+ // Standard file object generic mapping (used by MapGenericMask).
274
+ GENERIC_MAPPING m = new GENERIC_MAPPING();
275
+ m.GenericRead = 0x00120089; // FILE_GENERIC_READ
276
+ m.GenericWrite = 0x00120116; // FILE_GENERIC_WRITE
277
+ m.GenericExecute = 0x001200A0;// FILE_GENERIC_EXECUTE
278
+ m.GenericAll = 0x001F01FF; // FILE_ALL_ACCESS
279
+ return m;
280
+ }
281
+
282
+ private static readonly IntPtr INVALID_HANDLE = new IntPtr(-1);
283
+
284
+ // --- trusted-principal sets (Predicate A rule 1 / rule 2) ---------------
285
+ private static string CurrentUserSid()
286
+ {
287
+ WindowsIdentity id = WindowsIdentity.GetCurrent();
288
+ return id.User.Value;
289
+ }
290
+
291
+ private static string SystemSid()
292
+ {
293
+ return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Value;
294
+ }
295
+
296
+ private static string AdminsSid()
297
+ {
298
+ return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value;
299
+ }
300
+
301
+ // CREATOR OWNER (S-1-3-0) is treated as owner-equivalent by design: this is
302
+ // the ratified round-4 JDB-202 handling, NOT an accidental widening.
303
+ // Materialized non-inherit-only CREATOR OWNER ACEs are not normally
304
+ // attacker-grantable, and inherit-only ones are already skipped (rule 4).
305
+ private static string CreatorOwnerSid()
306
+ {
307
+ return new SecurityIdentifier(WellKnownSidType.CreatorOwnerSid, null).Value;
308
+ }
309
+
310
+ // Owner is trusted (rule 1): current-user, SYSTEM, Administrators,
311
+ // TrustedInstaller (owner-only), or a materialized CREATOR OWNER
312
+ // (owner-equivalent).
313
+ private static bool OwnerTrusted(string sid)
314
+ {
315
+ if (sid == null) return false;
316
+ return sid == CurrentUserSid() || sid == SystemSid() || sid == AdminsSid()
317
+ || sid == SID_TRUSTED_INSTALLER || sid == CreatorOwnerSid();
318
+ }
319
+
320
+ // A trustee is foreign (rule 2) unless it is current-user, SYSTEM,
321
+ // Administrators, or a materialized CREATOR OWNER (owner-equivalent).
322
+ // TrustedInstaller is NOT in the trustee allowlist (owner-only).
323
+ private static bool IsForeignTrustee(string sid)
324
+ {
325
+ if (sid == null) return true;
326
+ if (sid == CurrentUserSid() || sid == SystemSid() || sid == AdminsSid()
327
+ || sid == CreatorOwnerSid()) return false;
328
+ return true;
329
+ }
330
+
331
+ // --- opaque identity ----------------------------------------------------
332
+ // "<volumeSerialHex>:<fileIdHex>", lowercase; zero FileId is rejected by
333
+ // validOpaque in secure-fs-windows.ts, and refused here at capture too (C4).
334
+ private static string BuildOpaque(BY_HANDLE_FILE_INFORMATION info, out bool zero)
335
+ {
336
+ ulong fileId = ((ulong)info.nFileIndexHigh << 32) | (ulong)info.nFileIndexLow;
337
+ zero = (fileId == 0);
338
+ return info.dwVolumeSerialNumber.ToString("x") + ":" + fileId.ToString("x");
339
+ }
340
+
341
+ // --- Predicate B: self-relative, protected, owner-only allowlist DACL ---
342
+ private static byte[] BuildProtectedSd()
343
+ {
344
+ SecurityIdentifier user = WindowsIdentity.GetCurrent().User;
345
+ SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
346
+ SecurityIdentifier admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
347
+ RawAcl dacl = new RawAcl(GenericAcl.AclRevision, 3);
348
+ dacl.InsertAce(0, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed,
349
+ FILE_ALL_ACCESS, user, false, null));
350
+ dacl.InsertAce(1, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed,
351
+ FILE_ALL_ACCESS, system, false, null));
352
+ dacl.InsertAce(2, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed,
353
+ FILE_ALL_ACCESS, admins, false, null));
354
+ RawSecurityDescriptor sd = new RawSecurityDescriptor(
355
+ ControlFlags.DiscretionaryAclPresent
356
+ | ControlFlags.DiscretionaryAclProtected
357
+ | ControlFlags.SelfRelative,
358
+ user, null, null, dacl);
359
+ byte[] bin = new byte[sd.BinaryLength];
360
+ sd.GetBinaryForm(bin, 0);
361
+ return bin;
362
+ }
363
+
364
+ // Open a path no-follow on a FRESH handle. INVALID_HANDLE on failure with
365
+ // the win32 error in `err`. Caller MUST CloseHandle on success.
366
+ private static IntPtr OpenNoFollow(string path, uint access, uint share, out int err)
367
+ {
368
+ uint flags = FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS;
369
+ IntPtr h = CreateFileW(path, access, share, IntPtr.Zero, OPEN_EXISTING, flags, IntPtr.Zero);
370
+ err = Marshal.GetLastWin32Error();
371
+ return h;
372
+ }
373
+
374
+ // Read attributes + opaque from an open handle.
375
+ private static bool ReadInfo(IntPtr h, out uint attributes, out string opaque, out bool zeroId)
376
+ {
377
+ attributes = 0; opaque = null; zeroId = true;
378
+ BY_HANDLE_FILE_INFORMATION info;
379
+ if (!GetFileInformationByHandle(h, out info)) return false;
380
+ attributes = info.dwFileAttributes;
381
+ opaque = BuildOpaque(info, out zeroId);
382
+ return true;
383
+ }
384
+
385
+ // Read owner + DACL from an open handle into managed form.
386
+ // Returns false and sets refusal on any failure.
387
+ private static bool ReadSd(IntPtr h, out SecurityIdentifier owner,
388
+ out RawAcl dacl, out bool nullDacl, out string failDetail)
389
+ {
390
+ owner = null; dacl = null; nullDacl = false; failDetail = null;
391
+ IntPtr pOwner, pGroup, pDacl, pSacl, pSd;
392
+ uint rc = GetSecurityInfo(h, SE_FILE_OBJECT,
393
+ OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
394
+ out pOwner, out pGroup, out pDacl, out pSacl, out pSd);
395
+ if (rc != 0) { failDetail = "GetSecurityInfo " + rc; return false; }
396
+ try
397
+ {
398
+ int len = (int)GetSecurityDescriptorLength(pSd);
399
+ byte[] raw = new byte[len];
400
+ Marshal.Copy(pSd, raw, 0, len);
401
+ RawSecurityDescriptor rsd = new RawSecurityDescriptor(raw, 0);
402
+ owner = rsd.Owner;
403
+ dacl = rsd.DiscretionaryAcl;
404
+ nullDacl = (dacl == null); // NULL DACL grants everyone -> refuse
405
+ return true;
406
+ }
407
+ finally { LocalFree(pSd); }
408
+ }
409
+
410
+ // Evaluate a DACL against a refuse mask (Predicate A rules 2-6).
411
+ // objectEndanger = PATH_ENDANGER_DIR or PATH_ENDANGER_FILE, used to label
412
+ // a hit as "path-endangering" vs "add-child" (container add-child bits).
413
+ private static string EvaluateDacl(RawAcl dacl, bool nullDacl, int refuseMask, int objectEndanger)
414
+ {
415
+ if (nullDacl) return "null DACL";
416
+ if (dacl == null) return "null DACL";
417
+ GENERIC_MAPPING map = FileMapping();
418
+ for (int i = 0; i < dacl.Count; i++)
419
+ {
420
+ QualifiedAce qa = dacl[i] as QualifiedAce;
421
+ if (qa == null) return "unrecognized ACE";
422
+ if (qa.AceQualifier != AceQualifier.AccessAllowed) continue; // deny does not grant
423
+ if (((int)qa.AceFlags & (int)AceFlags.InheritOnly) != 0) continue; // IO template (rule 4)
424
+ string sid = qa.SecurityIdentifier.Value;
425
+ if (!IsForeignTrustee(sid)) continue;
426
+ uint m = (uint)qa.AccessMask;
427
+ MapGenericMask(ref m, ref map); // expand generic bits BEFORE masking (rule 3)
428
+ int expanded = (int)m;
429
+ int hit = expanded & refuseMask;
430
+ if (hit != 0)
431
+ {
432
+ string kind = ((hit & objectEndanger) != 0) ? "path-endangering" : "add-child";
433
+ return "foreign trustee " + sid + " " + kind;
434
+ }
435
+ }
436
+ return null; // clean
437
+ }
438
+
439
+ private static string ChildPath(string dir, string name)
440
+ {
441
+ return Path.Combine(dir, name);
442
+ }
443
+
444
+ // True if `path` contains no entries other than "." / "..". enumOk is false
445
+ // when enumeration itself could not be performed (treated as fail-closed by
446
+ // callers). Identity is pinned by a held handle across the call site.
447
+ private static bool DirIsEmpty(string path, out bool enumOk)
448
+ {
449
+ enumOk = false;
450
+ WIN32_FIND_DATA fd;
451
+ IntPtr hf = FindFirstFileW(Path.Combine(path, "*"), out fd);
452
+ if (hf == INVALID_HANDLE) return false;
453
+ try
454
+ {
455
+ enumOk = true;
456
+ do
457
+ {
458
+ string n = fd.cFileName;
459
+ if (n == "." || n == "..") continue;
460
+ return false; // a real child -> not empty
461
+ } while (FindNextFileW(hf, out fd));
462
+ return true;
463
+ }
464
+ finally { FindClose(hf); }
465
+ }
466
+
467
+ private static Held Lookup(string handleId)
468
+ {
469
+ if (handleId == null) return null;
470
+ Held held;
471
+ lock (Gate) { if (!Handles.TryGetValue(handleId, out held)) return null; }
472
+ return held;
473
+ }
474
+
475
+ // Register a fresh open directory handle in the retained table.
476
+ private static string Register(IntPtr h, string path, string opaque)
477
+ {
478
+ lock (Gate)
479
+ {
480
+ HandleSeq++;
481
+ string id = HandleSeq.ToString();
482
+ Held held = new Held();
483
+ held.Handle = h; held.Path = path; held.Opaque = opaque;
484
+ Handles[id] = held;
485
+ return id;
486
+ }
487
+ }
488
+
489
+ // --- create-with-SD helpers (Predicate B, atomic at creation) -----------
490
+ private static IntPtr CreateFileWithSd(string path, uint access, uint disp, uint flags, out int err)
491
+ {
492
+ byte[] sd = BuildProtectedSd();
493
+ GCHandle gh = GCHandle.Alloc(sd, GCHandleType.Pinned);
494
+ IntPtr pSa = IntPtr.Zero;
495
+ try
496
+ {
497
+ SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
498
+ sa.nLength = Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES));
499
+ sa.lpSecurityDescriptor = gh.AddrOfPinnedObject();
500
+ sa.bInheritHandle = 0;
501
+ pSa = Marshal.AllocHGlobal(sa.nLength);
502
+ Marshal.StructureToPtr(sa, pSa, false);
503
+ IntPtr h = CreateFileW(path, access, 0, pSa, disp, flags, IntPtr.Zero);
504
+ err = Marshal.GetLastWin32Error();
505
+ return h;
506
+ }
507
+ finally
508
+ {
509
+ if (pSa != IntPtr.Zero) Marshal.FreeHGlobal(pSa);
510
+ gh.Free();
511
+ }
512
+ }
513
+
514
+ private static bool CreateDirWithSd(string path, out int err)
515
+ {
516
+ byte[] sd = BuildProtectedSd();
517
+ GCHandle gh = GCHandle.Alloc(sd, GCHandleType.Pinned);
518
+ IntPtr pSa = IntPtr.Zero;
519
+ try
520
+ {
521
+ SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
522
+ sa.nLength = Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES));
523
+ sa.lpSecurityDescriptor = gh.AddrOfPinnedObject();
524
+ sa.bInheritHandle = 0;
525
+ pSa = Marshal.AllocHGlobal(sa.nLength);
526
+ Marshal.StructureToPtr(sa, pSa, false);
527
+ bool ok = CreateDirectoryW(path, pSa);
528
+ err = Marshal.GetLastWin32Error();
529
+ return ok;
530
+ }
531
+ finally
532
+ {
533
+ if (pSa != IntPtr.Zero) Marshal.FreeHGlobal(pSa);
534
+ gh.Free();
535
+ }
536
+ }
537
+
538
+ // ====================================================================
539
+ // OPS (one method per HelperOp; every path re-opens FRESH no-follow)
540
+ // ====================================================================
541
+
542
+ // openDir: retained parent-chain handle. Maps ONLY ERROR_FILE_NOT_FOUND /
543
+ // ERROR_PATH_NOT_FOUND to a notFound status; a junction OPENS (no-follow)
544
+ // and is refused by the reparse-attribute check with NO status (JDA6-001).
545
+ public static OpResult OpenDir(string path)
546
+ {
547
+ try
548
+ {
549
+ if (String.IsNullOrEmpty(path)) return OpResult.Fail(R_CHAIN, "openDir: empty path");
550
+ int err;
551
+ // Retained lock handle: share read+write but NOT delete.
552
+ IntPtr h = OpenNoFollow(path, READ_CONTROL | FILE_READ_ATTRIBUTES,
553
+ FILE_SHARE_READ | FILE_SHARE_WRITE, out err);
554
+ if (h == INVALID_HANDLE)
555
+ {
556
+ if (err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND)
557
+ return OpResult.FailStatus(R_CHAIN, "openDir not found " + path, err);
558
+ return OpResult.Fail(R_CHAIN, "openDir failed " + err + " " + path);
559
+ }
560
+ bool keep = false;
561
+ try
562
+ {
563
+ uint attr; string opaque; bool zeroId;
564
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
565
+ return OpResult.Fail(R_CHAIN, "openDir info failed " + path);
566
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
567
+ return OpResult.Fail(R_CHAIN, "openDir reparse point " + path);
568
+ if ((attr & FILE_ATTRIBUTE_DIRECTORY) == 0)
569
+ return OpResult.Fail(R_CHAIN, "openDir not a directory " + path); // notFound=false
570
+ if (zeroId)
571
+ return OpResult.Fail(R_CHAIN, "openDir unresolvable identity " + path);
572
+ string id = Register(h, path, opaque);
573
+ keep = true;
574
+ OpResult r = OpResult.Good();
575
+ r.HandleId = id; r.Opaque = opaque; r.Attributes = (int)attr;
576
+ return r;
577
+ }
578
+ finally { if (!keep) CloseHandle(h); }
579
+ }
580
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "openDir exception " + ex.Message); }
581
+ }
582
+
583
+ // revalidate: fresh no-follow re-open; refuse reparse; compare fresh opaque
584
+ // against the held token (C2 / REPARSE-4). Never answers from a stale handle.
585
+ public static OpResult Revalidate(string path, string heldOpaque)
586
+ {
587
+ try
588
+ {
589
+ if (String.IsNullOrEmpty(heldOpaque))
590
+ return OpResult.Fail(R_CHAIN, "revalidate unresolvable identity " + path);
591
+ int err;
592
+ IntPtr h = OpenNoFollow(path, READ_CONTROL | FILE_READ_ATTRIBUTES,
593
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
594
+ if (h == INVALID_HANDLE)
595
+ return OpResult.Fail(R_CHAIN, "revalidate open failed " + err + " " + path);
596
+ try
597
+ {
598
+ uint attr; string opaque; bool zeroId;
599
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
600
+ return OpResult.Fail(R_CHAIN, "revalidate info failed " + path);
601
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
602
+ return OpResult.Fail(R_CHAIN, "revalidate reparse point " + path);
603
+ if (zeroId)
604
+ return OpResult.Fail(R_CHAIN, "revalidate unresolvable identity " + path);
605
+ if (!String.Equals(opaque, heldOpaque, StringComparison.OrdinalIgnoreCase))
606
+ return OpResult.Fail(R_CHAIN, "revalidate identity changed " + path);
607
+ return OpResult.Good();
608
+ }
609
+ finally { CloseHandle(h); }
610
+ }
611
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "revalidate exception " + ex.Message); }
612
+ }
613
+
614
+ // proveOwner: Predicate A rule 1 on a fresh no-follow handle.
615
+ public static OpResult ProveOwner(string path)
616
+ {
617
+ try
618
+ {
619
+ int err;
620
+ IntPtr h = OpenNoFollow(path, READ_CONTROL | FILE_READ_ATTRIBUTES,
621
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
622
+ if (h == INVALID_HANDLE)
623
+ return OpResult.Fail(R_DACL, "proveOwner open failed " + err + " " + path);
624
+ try
625
+ {
626
+ uint attr; string opaque; bool zeroId;
627
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
628
+ return OpResult.Fail(R_DACL, "proveOwner info failed " + path);
629
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
630
+ return OpResult.Fail(R_CHAIN, "proveOwner reparse point " + path);
631
+ SecurityIdentifier owner; RawAcl dacl; bool nullDacl; string sdErr;
632
+ if (!ReadSd(h, out owner, out dacl, out nullDacl, out sdErr))
633
+ return OpResult.Fail(R_DACL, "proveOwner " + sdErr + " " + path);
634
+ string osid = (owner == null) ? null : owner.Value;
635
+ if (!OwnerTrusted(osid))
636
+ return OpResult.Fail(R_DACL, "foreign owner " + osid);
637
+ return OpResult.Good();
638
+ }
639
+ finally { CloseHandle(h); }
640
+ }
641
+ catch (Exception ex) { return OpResult.Fail(R_DACL, "proveOwner exception " + ex.Message); }
642
+ }
643
+
644
+ // proveDacl: Predicate A rules 2-6, object-type-aware mask, fresh handle.
645
+ public static OpResult ProveDacl(string path)
646
+ {
647
+ return ProveDaclInternal(path, false);
648
+ }
649
+
650
+ // proveContainer: proveManagedContainer -> owner trusted AND CREATE_PARENT_DIR
651
+ // add-child refusal (JDB-201/F1 + JDA-401). Containers are directories.
652
+ public static OpResult ProveContainer(string path)
653
+ {
654
+ return ProveDaclInternal(path, true);
655
+ }
656
+
657
+ private static OpResult ProveDaclInternal(string path, bool container)
658
+ {
659
+ // Disambiguate the audit trail: a container refusal must not be mislabeled
660
+ // "proveDacl" (mirror this tag in every detail string below).
661
+ string tag = container ? "proveContainer" : "proveDacl";
662
+ try
663
+ {
664
+ int err;
665
+ IntPtr h = OpenNoFollow(path, READ_CONTROL | FILE_READ_ATTRIBUTES,
666
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
667
+ if (h == INVALID_HANDLE)
668
+ return OpResult.Fail(R_DACL, tag + " open failed " + err + " " + path);
669
+ try
670
+ {
671
+ uint attr; string opaque; bool zeroId;
672
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
673
+ return OpResult.Fail(R_DACL, tag + " info failed " + path);
674
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
675
+ return OpResult.Fail(R_CHAIN, tag + " reparse point " + path);
676
+ bool isDir = (attr & FILE_ATTRIBUTE_DIRECTORY) != 0;
677
+ SecurityIdentifier owner; RawAcl dacl; bool nullDacl; string sdErr;
678
+ if (!ReadSd(h, out owner, out dacl, out nullDacl, out sdErr))
679
+ return OpResult.Fail(R_DACL, tag + " " + sdErr + " " + path);
680
+ if (container)
681
+ {
682
+ string osid = (owner == null) ? null : owner.Value;
683
+ if (!OwnerTrusted(osid))
684
+ return OpResult.Fail(R_DACL, "foreign owner " + osid);
685
+ }
686
+ int objectEndanger = isDir ? PATH_ENDANGER_DIR : PATH_ENDANGER_FILE;
687
+ int refuseMask = container ? CREATE_PARENT_DIR : objectEndanger;
688
+ string detail = EvaluateDacl(dacl, nullDacl, refuseMask, objectEndanger);
689
+ if (detail != null) return OpResult.Fail(R_DACL, detail);
690
+ return OpResult.Good();
691
+ }
692
+ finally { CloseHandle(h); }
693
+ }
694
+ catch (Exception ex) { return OpResult.Fail(R_DACL, tag + " exception " + ex.Message); }
695
+ }
696
+
697
+ // createDir: CREATE_NEW (exclusive) directory relative to the parent's
698
+ // locked path, born with the Predicate B protected owner-only DACL, then
699
+ // re-opened no-follow and retained (defense in depth re-proves TS-side).
700
+ public static OpResult CreateDir(string parentHandleId, string name)
701
+ {
702
+ try
703
+ {
704
+ Held parent = Lookup(parentHandleId);
705
+ if (parent == null) return OpResult.Fail(R_CHAIN, "createDir unknown parent handle");
706
+ if (String.IsNullOrEmpty(name)) return OpResult.Fail(R_CHAIN, "createDir empty name");
707
+ string full = ChildPath(parent.Path, name);
708
+ int err;
709
+ if (!CreateDirWithSd(full, out err))
710
+ {
711
+ if (err == ERROR_ALREADY_EXISTS || err == ERROR_FILE_EXISTS)
712
+ return OpResult.Fail(R_CHAIN, "createDir exists " + full); // O_EXCL analog
713
+ return OpResult.Fail(R_CHAIN, "createDir failed " + err + " " + full);
714
+ }
715
+ IntPtr h = OpenNoFollow(full, READ_CONTROL | FILE_READ_ATTRIBUTES,
716
+ FILE_SHARE_READ | FILE_SHARE_WRITE, out err);
717
+ if (h == INVALID_HANDLE)
718
+ return OpResult.Fail(R_CHAIN, "createDir reopen failed " + err + " " + full);
719
+ bool keep = false;
720
+ try
721
+ {
722
+ uint attr; string opaque; bool zeroId;
723
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
724
+ return OpResult.Fail(R_CHAIN, "createDir info failed " + full);
725
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
726
+ return OpResult.Fail(R_CHAIN, "createDir reparse point " + full);
727
+ if ((attr & FILE_ATTRIBUTE_DIRECTORY) == 0)
728
+ return OpResult.Fail(R_CHAIN, "createDir not a directory " + full);
729
+ if (zeroId)
730
+ return OpResult.Fail(R_CHAIN, "createDir unresolvable identity " + full);
731
+ string id = Register(h, full, opaque);
732
+ keep = true;
733
+ OpResult r = OpResult.Good();
734
+ r.HandleId = id; r.Opaque = opaque; r.Attributes = (int)attr;
735
+ return r;
736
+ }
737
+ finally { if (!keep) CloseHandle(h); }
738
+ }
739
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "createDir exception " + ex.Message); }
740
+ }
741
+
742
+ // capture: S_ISREG-equivalent regular-file assert on a fresh no-follow
743
+ // handle; returns base64 bytes + opaque. Never dereferences a reparse point.
744
+ public static OpResult Capture(string path)
745
+ {
746
+ try
747
+ {
748
+ int err;
749
+ IntPtr h = OpenNoFollow(path, GENERIC_READ | FILE_READ_ATTRIBUTES,
750
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
751
+ if (h == INVALID_HANDLE)
752
+ return OpResult.Fail(R_CHAIN, "capture open failed " + err + " " + path);
753
+ try
754
+ {
755
+ uint attr; string opaque; bool zeroId;
756
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
757
+ return OpResult.Fail(R_CHAIN, "capture info failed " + path);
758
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
759
+ return OpResult.Fail(R_CHAIN, "capture reparse point " + path);
760
+ if ((attr & FILE_ATTRIBUTE_DIRECTORY) != 0)
761
+ return OpResult.Fail(R_CHAIN, "capture not a regular file " + path);
762
+ if (zeroId)
763
+ return OpResult.Fail(R_CHAIN, "capture unresolvable identity " + path);
764
+ byte[] bytes;
765
+ using (SafeFileHandle sfh = new SafeFileHandle(h, false))
766
+ using (FileStream fs = new FileStream(sfh, FileAccess.Read))
767
+ using (MemoryStream ms = new MemoryStream())
768
+ {
769
+ fs.CopyTo(ms);
770
+ bytes = ms.ToArray();
771
+ }
772
+ OpResult r = OpResult.Good();
773
+ r.BytesB64 = Convert.ToBase64String(bytes);
774
+ r.Opaque = opaque;
775
+ return r;
776
+ }
777
+ finally { CloseHandle(h); }
778
+ }
779
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "capture exception " + ex.Message); }
780
+ }
781
+
782
+ // writeExcl: CREATE_NEW file relative to the locked dir path, born with the
783
+ // Predicate B protected owner-only DACL; O_EXCL refusal on a pre-existing
784
+ // target; flush before close.
785
+ public static OpResult WriteExcl(string dirHandleId, string name, string bytesB64)
786
+ {
787
+ try
788
+ {
789
+ Held dir = Lookup(dirHandleId);
790
+ if (dir == null) return OpResult.Fail(R_CHAIN, "writeExcl unknown dir handle");
791
+ if (String.IsNullOrEmpty(name)) return OpResult.Fail(R_CHAIN, "writeExcl empty name");
792
+ byte[] bytes;
793
+ try { bytes = Convert.FromBase64String(bytesB64 == null ? "" : bytesB64); }
794
+ catch { return OpResult.Fail(R_CHAIN, "writeExcl bad base64"); }
795
+ string full = ChildPath(dir.Path, name);
796
+ int err;
797
+ IntPtr h = CreateFileWithSd(full, GENERIC_WRITE, CREATE_NEW,
798
+ FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, out err);
799
+ if (h == INVALID_HANDLE)
800
+ {
801
+ if (err == ERROR_FILE_EXISTS || err == ERROR_ALREADY_EXISTS)
802
+ return OpResult.Fail(R_CHAIN, "writeExcl exists " + full); // O_EXCL analog
803
+ return OpResult.Fail(R_CHAIN, "writeExcl failed " + err + " " + full);
804
+ }
805
+ try
806
+ {
807
+ using (SafeFileHandle sfh = new SafeFileHandle(h, false))
808
+ using (FileStream fs = new FileStream(sfh, FileAccess.Write))
809
+ {
810
+ fs.Write(bytes, 0, bytes.Length);
811
+ fs.Flush();
812
+ }
813
+ FlushFileBuffers(h);
814
+ return OpResult.Good();
815
+ }
816
+ finally { CloseHandle(h); }
817
+ }
818
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "writeExcl exception " + ex.Message); }
819
+ }
820
+
821
+ // applyMode: NOT a numeric mode. Re-open no-follow, re-assert the Predicate B
822
+ // protected owner-only DACL (idempotent, repairs drift), then re-prove
823
+ // Predicate A on a fresh handle (Decision 1a).
824
+ public static OpResult ApplyMode(string path)
825
+ {
826
+ try
827
+ {
828
+ int err;
829
+ IntPtr h = OpenNoFollow(path,
830
+ WRITE_DAC_A | WRITE_OWNER_A | READ_CONTROL | FILE_READ_ATTRIBUTES,
831
+ FILE_SHARE_READ | FILE_SHARE_WRITE, out err);
832
+ if (h == INVALID_HANDLE)
833
+ return OpResult.Fail(R_CHAIN, "applyMode open failed " + err + " " + path);
834
+ try
835
+ {
836
+ uint attr; string opaque; bool zeroId;
837
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
838
+ return OpResult.Fail(R_CHAIN, "applyMode info failed " + path);
839
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
840
+ return OpResult.Fail(R_CHAIN, "applyMode reparse point " + path);
841
+ byte[] sd = BuildProtectedSd();
842
+ if (!SetKernelObjectSecurity(h,
843
+ OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
844
+ | PROTECTED_DACL_SECURITY_INFORMATION, sd))
845
+ return OpResult.Fail(R_DACL, "applyMode set security " + Marshal.GetLastWin32Error());
846
+ }
847
+ finally { CloseHandle(h); }
848
+ // Re-prove Predicate A on a FRESH handle (file object).
849
+ return ProveDacl(path);
850
+ }
851
+ catch (Exception ex) { return OpResult.Fail(R_DACL, "applyMode exception " + ex.Message); }
852
+ }
853
+
854
+ // rename: MoveFileEx REPLACE_EXISTING | WRITE_THROUGH within the locked dir,
855
+ // then FlushFileBuffers on the retained dir handle (durable-commit).
856
+ public static OpResult Rename(string dirHandleId, string from, string to)
857
+ {
858
+ try
859
+ {
860
+ Held dir = Lookup(dirHandleId);
861
+ if (dir == null) return OpResult.Fail(R_CHAIN, "rename unknown dir handle");
862
+ if (String.IsNullOrEmpty(from) || String.IsNullOrEmpty(to))
863
+ return OpResult.Fail(R_CHAIN, "rename empty name");
864
+ string fromFull = ChildPath(dir.Path, from);
865
+ string toFull = ChildPath(dir.Path, to);
866
+ // No opaque re-check here (unlike unlink/rmdir): both endpoints are our
867
+ // own just-created nodes under the held parent-chain lock, so no on-path
868
+ // node can be swapped while the chain handle is held.
869
+ if (!MoveFileExW(fromFull, toFull,
870
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
871
+ return OpResult.Fail(R_CHAIN, "rename failed " + Marshal.GetLastWin32Error()
872
+ + " " + fromFull);
873
+ FlushFileBuffers(dir.Handle);
874
+ return OpResult.Good();
875
+ }
876
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "rename exception " + ex.Message); }
877
+ }
878
+
879
+ // unlink: open the child no-follow relative to the locked dir path, verify
880
+ // full-precision identity against the held opaque (C4), then delete via
881
+ // FileDispositionInfo. Zero/absent/changed identity refuses.
882
+ public static OpResult Unlink(string dirHandleId, string name, string heldOpaque)
883
+ {
884
+ try
885
+ {
886
+ Held dir = Lookup(dirHandleId);
887
+ if (dir == null) return OpResult.Fail(R_CHAIN, "unlink unknown dir handle");
888
+ if (String.IsNullOrEmpty(name)) return OpResult.Fail(R_CHAIN, "unlink empty name");
889
+ if (String.IsNullOrEmpty(heldOpaque))
890
+ return OpResult.Fail(R_CHAIN, "unlink unresolvable identity " + name);
891
+ string full = ChildPath(dir.Path, name);
892
+ int err;
893
+ IntPtr h = OpenNoFollow(full, DELETE_A | FILE_READ_ATTRIBUTES,
894
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
895
+ if (h == INVALID_HANDLE)
896
+ return OpResult.Fail(R_CHAIN, "unlink open failed " + err + " " + full);
897
+ try
898
+ {
899
+ uint attr; string opaque; bool zeroId;
900
+ if (!ReadInfo(h, out attr, out opaque, out zeroId))
901
+ return OpResult.Fail(R_CHAIN, "unlink info failed " + full);
902
+ if ((attr & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
903
+ return OpResult.Fail(R_CHAIN, "unlink reparse point " + full);
904
+ if (zeroId)
905
+ return OpResult.Fail(R_CHAIN, "unlink unresolvable identity " + full);
906
+ if (!String.Equals(opaque, heldOpaque, StringComparison.OrdinalIgnoreCase))
907
+ return OpResult.Fail(R_CHAIN, "unlink identity changed " + full);
908
+ FILE_DISPOSITION_INFO info = new FILE_DISPOSITION_INFO();
909
+ info.DeleteFile = 1;
910
+ if (!SetFileInformationByHandle(h, FileDispositionInfo, ref info,
911
+ Marshal.SizeOf(typeof(FILE_DISPOSITION_INFO))))
912
+ return OpResult.Fail(R_CHAIN, "unlink delete failed " + Marshal.GetLastWin32Error());
913
+ return OpResult.Good();
914
+ }
915
+ finally { CloseHandle(h); }
916
+ }
917
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "unlink exception " + ex.Message); }
918
+ }
919
+
920
+ // rmdir: delete a tx-created empty directory (Phase-5 CI-validated: rmdir of a
921
+ // tx-created empty dir succeeds; rmdir of a non-empty or identity-drifted dir
922
+ // refuses).
923
+ //
924
+ // WHY the release/reopen dance (do NOT collapse it to RemoveDirectoryW on the
925
+ // retained handle): the retained lock handle (dir.Handle, opened in OpenDir /
926
+ // CreateDir) was opened with FILE_SHARE_READ|FILE_SHARE_WRITE and NO
927
+ // FILE_SHARE_DELETE. On Win32 a DELETE-access open -- which RemoveDirectory and
928
+ // delete-on-close both perform internally -- requires EVERY existing handle to
929
+ // the target to permit FILE_SHARE_DELETE. Our own retained no-share-delete
930
+ // handle therefore blocks deletion of the very directory it protects, failing
931
+ // with ERROR_SHARING_VIOLATION (32). So we MUST release the retained handle
932
+ // before deleting. To keep that release->reopen window safe we bracket it with
933
+ // a double opaque re-check: verify+capture identity on the retained handle,
934
+ // release it, re-open no-follow for DELETE with FILE_SHARE_DELETE, then
935
+ // re-verify the SAME opaque on the fresh handle (catches a swap in the tiny
936
+ // gap). Deletion is delete-on-close on that fresh handle, so we never issue a
937
+ // by-path RemoveDirectoryW (which would reintroduce a by-path TOCTOU).
938
+ // Fail-closed: any drift / non-empty refuses and the dir is left
939
+ // released-but-present, which is safe for a tx-created empty dir.
940
+ public static OpResult Rmdir(string dirHandleId, string heldOpaque)
941
+ {
942
+ try
943
+ {
944
+ Held dir = Lookup(dirHandleId);
945
+ if (dir == null) return OpResult.Fail(R_CHAIN, "rmdir unknown handle");
946
+ if (String.IsNullOrEmpty(heldOpaque))
947
+ return OpResult.Fail(R_CHAIN, "rmdir unresolvable identity " + dir.Path);
948
+
949
+ // 1) Verify identity + emptiness on the RETAINED handle; capture opaque.
950
+ // A kernel handle is bound to its file object, so its identity cannot
951
+ // drift; emptiness is enumerated by-path while the retained handle
952
+ // (no delete share) pins the directory against rename/delete.
953
+ uint attr0; string opaque0; bool zeroId0;
954
+ if (!ReadInfo(dir.Handle, out attr0, out opaque0, out zeroId0))
955
+ return OpResult.Fail(R_CHAIN, "rmdir info failed " + dir.Path);
956
+ if ((attr0 & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
957
+ return OpResult.Fail(R_CHAIN, "rmdir reparse point " + dir.Path);
958
+ if (zeroId0)
959
+ return OpResult.Fail(R_CHAIN, "rmdir unresolvable identity " + dir.Path);
960
+ if (!String.Equals(opaque0, heldOpaque, StringComparison.OrdinalIgnoreCase))
961
+ return OpResult.Fail(R_CHAIN, "rmdir identity changed " + dir.Path);
962
+ bool enumOk0;
963
+ if (!DirIsEmpty(dir.Path, out enumOk0))
964
+ {
965
+ if (!enumOk0) return OpResult.Fail(R_CHAIN, "rmdir enumerate failed " + dir.Path);
966
+ return OpResult.Fail(R_CHAIN, "rmdir not empty " + dir.Path);
967
+ }
968
+ string captured = opaque0;
969
+
970
+ // 2) Release the retained no-share-delete handle so it stops blocking
971
+ // the DELETE-access open below.
972
+ CloseAndForget(dirHandleId);
973
+
974
+ // 3) Re-open the path no-follow for DELETE, sharing delete this time.
975
+ int err;
976
+ IntPtr h = OpenNoFollow(dir.Path, DELETE_A | FILE_READ_ATTRIBUTES,
977
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, out err);
978
+ if (h == INVALID_HANDLE)
979
+ return OpResult.Fail(R_CHAIN, "rmdir delete-open failed " + err + " " + dir.Path);
980
+ try
981
+ {
982
+ // 4a) Re-verify identity on the fresh handle: refuse reparse / zero
983
+ // id, and require the SAME opaque captured in step 1 (this closes
984
+ // the tiny close->reopen swap window).
985
+ uint attr1; string opaque1; bool zeroId1;
986
+ if (!ReadInfo(h, out attr1, out opaque1, out zeroId1))
987
+ return OpResult.Fail(R_CHAIN, "rmdir info failed " + dir.Path);
988
+ if ((attr1 & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
989
+ return OpResult.Fail(R_CHAIN, "rmdir reparse point " + dir.Path);
990
+ if (zeroId1)
991
+ return OpResult.Fail(R_CHAIN, "rmdir unresolvable identity " + dir.Path);
992
+ if (!String.Equals(opaque1, captured, StringComparison.OrdinalIgnoreCase))
993
+ return OpResult.Fail(R_CHAIN, "rmdir identity changed " + dir.Path);
994
+ // 4b) Re-verify emptiness now that we hold the fresh handle.
995
+ bool enumOk1;
996
+ if (!DirIsEmpty(dir.Path, out enumOk1))
997
+ {
998
+ if (!enumOk1) return OpResult.Fail(R_CHAIN, "rmdir enumerate failed " + dir.Path);
999
+ return OpResult.Fail(R_CHAIN, "rmdir not empty " + dir.Path);
1000
+ }
1001
+ // 5) Delete-on-close: FileDispositionInfo removes the directory when
1002
+ // this fresh handle closes. It also returns ERROR_DIR_NOT_EMPTY if
1003
+ // a child slipped in after 4b -> fail-closed, no by-path TOCTOU.
1004
+ FILE_DISPOSITION_INFO info = new FILE_DISPOSITION_INFO();
1005
+ info.DeleteFile = 1;
1006
+ if (!SetFileInformationByHandle(h, FileDispositionInfo, ref info,
1007
+ Marshal.SizeOf(typeof(FILE_DISPOSITION_INFO))))
1008
+ {
1009
+ int derr = Marshal.GetLastWin32Error();
1010
+ if (derr == ERROR_DIR_NOT_EMPTY)
1011
+ return OpResult.Fail(R_CHAIN, "rmdir not empty " + dir.Path);
1012
+ return OpResult.Fail(R_CHAIN, "rmdir delete failed " + derr + " " + dir.Path);
1013
+ }
1014
+ return OpResult.Good();
1015
+ }
1016
+ finally { CloseHandle(h); }
1017
+ }
1018
+ catch (Exception ex) { return OpResult.Fail(R_CHAIN, "rmdir exception " + ex.Message); }
1019
+ }
1020
+
1021
+ // releaseHandle: always succeeds (the adapter balances the session handle
1022
+ // count on ANY ok openDir/createDir, even a null/unknown id).
1023
+ public static OpResult ReleaseHandle(string handleId)
1024
+ {
1025
+ try { CloseAndForget(handleId); }
1026
+ catch { /* fail-open on release is safe: the process exit hook kills all */ }
1027
+ return OpResult.Good();
1028
+ }
1029
+
1030
+ private static void CloseAndForget(string handleId)
1031
+ {
1032
+ if (handleId == null) return;
1033
+ Held held = null;
1034
+ lock (Gate)
1035
+ {
1036
+ if (Handles.TryGetValue(handleId, out held)) Handles.Remove(handleId);
1037
+ }
1038
+ if (held != null && held.Handle != IntPtr.Zero && held.Handle != INVALID_HANDLE)
1039
+ CloseHandle(held.Handle);
1040
+ }
1041
+
1042
+ // Best-effort close of every retained handle at process shutdown.
1043
+ public static void CloseAll()
1044
+ {
1045
+ lock (Gate)
1046
+ {
1047
+ foreach (KeyValuePair<string, Held> kv in Handles)
1048
+ {
1049
+ try
1050
+ {
1051
+ if (kv.Value.Handle != IntPtr.Zero && kv.Value.Handle != INVALID_HANDLE)
1052
+ CloseHandle(kv.Value.Handle);
1053
+ }
1054
+ catch { }
1055
+ }
1056
+ Handles.Clear();
1057
+ }
1058
+ }
1059
+ }
1060
+ }
1061
+ '@
1062
+
1063
+ Add-Type -TypeDefinition $csharp -Language CSharp | Out-Null
1064
+
1065
+ # --- framed raw binary stdio (W2: only frames on stdout, diagnostics to stderr) -
1066
+
1067
+ $script:stdin = [Console]::OpenStandardInput()
1068
+ $script:stdout = [Console]::OpenStandardOutput()
1069
+
1070
+ function Read-Exact([int]$count) {
1071
+ if ($count -eq 0) { return , (New-Object byte[] 0) }
1072
+ $buf = New-Object byte[] $count
1073
+ $off = 0
1074
+ while ($off -lt $count) {
1075
+ $r = $script:stdin.Read($buf, $off, $count - $off)
1076
+ if ($r -le 0) { return $null } # EOF -> parent closed stdin
1077
+ $off += $r
1078
+ }
1079
+ return , $buf
1080
+ }
1081
+
1082
+ function Write-Frame($obj) {
1083
+ $json = ConvertTo-Json $obj -Compress -Depth 6
1084
+ $bytes = [Text.Encoding]::UTF8.GetBytes($json)
1085
+ $len = $bytes.Length
1086
+ $hdr = New-Object byte[] 4
1087
+ $hdr[0] = [byte](($len -shr 24) -band 0xFF)
1088
+ $hdr[1] = [byte](($len -shr 16) -band 0xFF)
1089
+ $hdr[2] = [byte](($len -shr 8) -band 0xFF)
1090
+ $hdr[3] = [byte]($len -band 0xFF)
1091
+ $script:stdout.Write($hdr, 0, 4)
1092
+ $script:stdout.Write($bytes, 0, $len)
1093
+ $script:stdout.Flush()
1094
+ }
1095
+
1096
+ # --- response shaping (must match HelperResponse in secure-fs-windows.ts) ------
1097
+
1098
+ function Send-Void($r) {
1099
+ if ($r.Ok) { Write-Frame @{ ok = $true } ; return }
1100
+ $resp = @{ ok = $false }
1101
+ if ($r.Refusal) { $resp['refusal'] = $r.Refusal }
1102
+ if ($r.Detail) { $resp['detail'] = $r.Detail }
1103
+ Write-Frame $resp
1104
+ }
1105
+
1106
+ function Send-Handle($r) {
1107
+ if ($r.Ok) {
1108
+ Write-Frame @{ ok = $true; value = @{ handleId = $r.HandleId; opaque = $r.Opaque; attributes = $r.Attributes } }
1109
+ return
1110
+ }
1111
+ $resp = @{ ok = $false }
1112
+ if ($r.Refusal) { $resp['refusal'] = $r.Refusal }
1113
+ if ($r.Detail) { $resp['detail'] = $r.Detail }
1114
+ if ($r.Status -gt 0) { $resp['status'] = $r.Status }
1115
+ Write-Frame $resp
1116
+ }
1117
+
1118
+ function Send-Capture($r) {
1119
+ if ($r.Ok) {
1120
+ Write-Frame @{ ok = $true; value = @{ bytes = $r.BytesB64; opaque = $r.Opaque } }
1121
+ return
1122
+ }
1123
+ $resp = @{ ok = $false }
1124
+ if ($r.Refusal) { $resp['refusal'] = $r.Refusal }
1125
+ if ($r.Detail) { $resp['detail'] = $r.Detail }
1126
+ Write-Frame $resp
1127
+ }
1128
+
1129
+ function Get-Arg($obj, [string]$name) {
1130
+ if ($null -eq $obj) { return $null }
1131
+ $p = $obj.PSObject.Properties[$name]
1132
+ if ($null -eq $p) { return $null }
1133
+ return $p.Value
1134
+ }
1135
+
1136
+ function AsStr($v) {
1137
+ if ($null -eq $v) { return $null }
1138
+ return [string]$v
1139
+ }
1140
+
1141
+ # --- session loop -------------------------------------------------------------
1142
+
1143
+ Write-Frame @{ ready = $true; protocolVersion = $script:PROTOCOL_VERSION }
1144
+
1145
+ try {
1146
+ while ($true) {
1147
+ $hdr = Read-Exact 4
1148
+ if ($null -eq $hdr) { break } # EOF -> clean shutdown
1149
+ $len = ([int]$hdr[0] -shl 24) -bor ([int]$hdr[1] -shl 16) -bor ([int]$hdr[2] -shl 8) -bor [int]$hdr[3]
1150
+ if ($len -lt 0 -or $len -gt $script:FRAME_LIMIT) {
1151
+ [Console]::Error.WriteLine("oversized request frame: $len")
1152
+ break
1153
+ }
1154
+ $body = Read-Exact $len
1155
+ if ($null -eq $body) { break }
1156
+
1157
+ $req = $null
1158
+ try {
1159
+ $req = [Text.Encoding]::UTF8.GetString($body) | ConvertFrom-Json
1160
+ }
1161
+ catch {
1162
+ Write-Frame @{ ok = $false; refusal = 'unsafe-parent-chain'; detail = 'malformed request frame' }
1163
+ continue
1164
+ }
1165
+
1166
+ $op = AsStr (Get-Arg $req 'op')
1167
+ $a = Get-Arg $req 'args'
1168
+
1169
+ try {
1170
+ switch ($op) {
1171
+ 'openDir' {
1172
+ Send-Handle ([JaviForge.SecureObj]::OpenDir((AsStr (Get-Arg $a 'path'))))
1173
+ }
1174
+ 'revalidate' {
1175
+ Send-Void ([JaviForge.SecureObj]::Revalidate((AsStr (Get-Arg $a 'path')), (AsStr (Get-Arg $a 'opaque'))))
1176
+ }
1177
+ 'proveOwner' {
1178
+ Send-Void ([JaviForge.SecureObj]::ProveOwner((AsStr (Get-Arg $a 'path'))))
1179
+ }
1180
+ 'proveDacl' {
1181
+ Send-Void ([JaviForge.SecureObj]::ProveDacl((AsStr (Get-Arg $a 'path'))))
1182
+ }
1183
+ 'proveContainer' {
1184
+ Send-Void ([JaviForge.SecureObj]::ProveContainer((AsStr (Get-Arg $a 'path'))))
1185
+ }
1186
+ 'createDir' {
1187
+ Send-Handle ([JaviForge.SecureObj]::CreateDir((AsStr (Get-Arg $a 'parentHandle')), (AsStr (Get-Arg $a 'name'))))
1188
+ }
1189
+ 'capture' {
1190
+ Send-Capture ([JaviForge.SecureObj]::Capture((AsStr (Get-Arg $a 'path'))))
1191
+ }
1192
+ 'writeExcl' {
1193
+ Send-Void ([JaviForge.SecureObj]::WriteExcl((AsStr (Get-Arg $a 'dirHandle')), (AsStr (Get-Arg $a 'name')), (AsStr (Get-Arg $a 'bytes'))))
1194
+ }
1195
+ 'applyMode' {
1196
+ Send-Void ([JaviForge.SecureObj]::ApplyMode((AsStr (Get-Arg $a 'path'))))
1197
+ }
1198
+ 'rename' {
1199
+ Send-Void ([JaviForge.SecureObj]::Rename((AsStr (Get-Arg $a 'dirHandle')), (AsStr (Get-Arg $a 'from')), (AsStr (Get-Arg $a 'to'))))
1200
+ }
1201
+ 'unlink' {
1202
+ Send-Void ([JaviForge.SecureObj]::Unlink((AsStr (Get-Arg $a 'dirHandle')), (AsStr (Get-Arg $a 'name')), (AsStr (Get-Arg $a 'opaque'))))
1203
+ }
1204
+ 'rmdir' {
1205
+ Send-Void ([JaviForge.SecureObj]::Rmdir((AsStr (Get-Arg $a 'handle')), (AsStr (Get-Arg $a 'opaque'))))
1206
+ }
1207
+ 'releaseHandle' {
1208
+ Send-Void ([JaviForge.SecureObj]::ReleaseHandle((AsStr (Get-Arg $a 'handle'))))
1209
+ }
1210
+ default {
1211
+ Write-Frame @{ ok = $false; refusal = 'unsafe-parent-chain'; detail = "unknown op: $op" }
1212
+ }
1213
+ }
1214
+ }
1215
+ catch {
1216
+ [Console]::Error.WriteLine("op '$op' exception: $($_.Exception.Message)")
1217
+ Write-Frame @{ ok = $false; refusal = 'unsafe-parent-chain'; detail = "helper op exception" }
1218
+ }
1219
+ }
1220
+ }
1221
+ finally {
1222
+ try { [JaviForge.SecureObj]::CloseAll() } catch { }
1223
+ }