react-native-nitro-logger 0.1.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.
Files changed (205) hide show
  1. package/LICENSE +20 -0
  2. package/NitroLogger.podspec +29 -0
  3. package/README.md +214 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
  10. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
  11. package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
  12. package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
  13. package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
  14. package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
  15. package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
  16. package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
  17. package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
  18. package/docs/PARITY.md +330 -0
  19. package/docs/PRIVACY.md +341 -0
  20. package/eslint-plugin/README.md +238 -0
  21. package/eslint-plugin/index.js +62 -0
  22. package/eslint-plugin/rules/literal-subsystem.js +102 -0
  23. package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
  24. package/eslint-plugin/rules/no-derived-correlation.js +171 -0
  25. package/eslint-plugin/rules/no-dynamic-message.js +60 -0
  26. package/eslint-plugin/shared.js +1948 -0
  27. package/ios/Gzip.swift +203 -0
  28. package/ios/HybridFileSink.swift +291 -0
  29. package/ios/HybridNativeConsoleSink.swift +20 -0
  30. package/ios/LogFileWriter.swift +1280 -0
  31. package/ios/LogSecureFile.swift +296 -0
  32. package/ios/LogWriterRegistry.swift +444 -0
  33. package/ios/NativeConsoleWriter.swift +180 -0
  34. package/lib/module/Logger.js +381 -0
  35. package/lib/module/Logger.js.map +1 -0
  36. package/lib/module/ScopedLogger.js +70 -0
  37. package/lib/module/ScopedLogger.js.map +1 -0
  38. package/lib/module/config.js +23 -0
  39. package/lib/module/config.js.map +1 -0
  40. package/lib/module/destinations/Batcher.js +647 -0
  41. package/lib/module/destinations/Batcher.js.map +1 -0
  42. package/lib/module/destinations/ConsoleDestination.js +51 -0
  43. package/lib/module/destinations/ConsoleDestination.js.map +1 -0
  44. package/lib/module/destinations/FileDestination.js +360 -0
  45. package/lib/module/destinations/FileDestination.js.map +1 -0
  46. package/lib/module/destinations/NativeConsoleDestination.js +170 -0
  47. package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
  48. package/lib/module/destinations/types.js +4 -0
  49. package/lib/module/destinations/types.js.map +1 -0
  50. package/lib/module/formatters/DefaultFormatter.js +136 -0
  51. package/lib/module/formatters/DefaultFormatter.js.map +1 -0
  52. package/lib/module/formatters/JsonLinesFormatter.js +162 -0
  53. package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
  54. package/lib/module/formatters/timestamp.js +15 -0
  55. package/lib/module/formatters/timestamp.js.map +1 -0
  56. package/lib/module/formatters/types.js +4 -0
  57. package/lib/module/formatters/types.js.map +1 -0
  58. package/lib/module/index.js +31 -0
  59. package/lib/module/index.js.map +1 -0
  60. package/lib/module/integrations/appState.js +81 -0
  61. package/lib/module/integrations/appState.js.map +1 -0
  62. package/lib/module/integrations/errorHandler.js +166 -0
  63. package/lib/module/integrations/errorHandler.js.map +1 -0
  64. package/lib/module/integrations/sanitizeError.js +238 -0
  65. package/lib/module/integrations/sanitizeError.js.map +1 -0
  66. package/lib/module/levels.js +35 -0
  67. package/lib/module/levels.js.map +1 -0
  68. package/lib/module/metadata.js +52 -0
  69. package/lib/module/metadata.js.map +1 -0
  70. package/lib/module/package.json +1 -0
  71. package/lib/module/privacy.js +349 -0
  72. package/lib/module/privacy.js.map +1 -0
  73. package/lib/module/specs/FileSink.nitro.js +4 -0
  74. package/lib/module/specs/FileSink.nitro.js.map +1 -0
  75. package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
  76. package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
  77. package/lib/module/types.js +4 -0
  78. package/lib/module/types.js.map +1 -0
  79. package/lib/module/utf8.js +42 -0
  80. package/lib/module/utf8.js.map +1 -0
  81. package/lib/typescript/package.json +1 -0
  82. package/lib/typescript/src/Logger.d.ts +122 -0
  83. package/lib/typescript/src/Logger.d.ts.map +1 -0
  84. package/lib/typescript/src/ScopedLogger.d.ts +36 -0
  85. package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
  86. package/lib/typescript/src/config.d.ts +12 -0
  87. package/lib/typescript/src/config.d.ts.map +1 -0
  88. package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
  89. package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
  90. package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
  91. package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
  92. package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
  93. package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
  94. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
  95. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
  96. package/lib/typescript/src/destinations/types.d.ts +19 -0
  97. package/lib/typescript/src/destinations/types.d.ts.map +1 -0
  98. package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
  99. package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
  100. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
  101. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
  102. package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
  103. package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
  104. package/lib/typescript/src/formatters/types.d.ts +27 -0
  105. package/lib/typescript/src/formatters/types.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +34 -0
  107. package/lib/typescript/src/index.d.ts.map +1 -0
  108. package/lib/typescript/src/integrations/appState.d.ts +33 -0
  109. package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
  110. package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
  111. package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
  112. package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
  113. package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
  114. package/lib/typescript/src/levels.d.ts +9 -0
  115. package/lib/typescript/src/levels.d.ts.map +1 -0
  116. package/lib/typescript/src/metadata.d.ts +23 -0
  117. package/lib/typescript/src/metadata.d.ts.map +1 -0
  118. package/lib/typescript/src/privacy.d.ts +103 -0
  119. package/lib/typescript/src/privacy.d.ts.map +1 -0
  120. package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
  121. package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
  122. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
  123. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
  124. package/lib/typescript/src/types.d.ts +37 -0
  125. package/lib/typescript/src/types.d.ts.map +1 -0
  126. package/lib/typescript/src/utf8.d.ts +15 -0
  127. package/lib/typescript/src/utf8.d.ts.map +1 -0
  128. package/nitro.json +33 -0
  129. package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
  130. package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
  131. package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
  132. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
  133. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
  134. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
  136. package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
  137. package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
  138. package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
  148. package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
  149. package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
  150. package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
  151. package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
  152. package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
  153. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
  154. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
  155. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
  156. package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
  157. package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
  158. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
  159. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
  160. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
  161. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
  162. package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
  163. package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
  164. package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
  165. package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
  166. package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
  167. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
  168. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
  169. package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
  170. package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
  171. package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
  172. package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
  173. package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
  174. package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
  175. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
  176. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
  177. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
  178. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
  179. package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
  180. package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
  181. package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
  182. package/package.json +151 -0
  183. package/src/Logger.ts +417 -0
  184. package/src/ScopedLogger.ts +94 -0
  185. package/src/config.ts +25 -0
  186. package/src/destinations/Batcher.ts +805 -0
  187. package/src/destinations/ConsoleDestination.ts +60 -0
  188. package/src/destinations/FileDestination.ts +473 -0
  189. package/src/destinations/NativeConsoleDestination.ts +220 -0
  190. package/src/destinations/types.ts +19 -0
  191. package/src/formatters/DefaultFormatter.ts +150 -0
  192. package/src/formatters/JsonLinesFormatter.ts +195 -0
  193. package/src/formatters/timestamp.ts +12 -0
  194. package/src/formatters/types.ts +29 -0
  195. package/src/index.tsx +106 -0
  196. package/src/integrations/appState.ts +111 -0
  197. package/src/integrations/errorHandler.ts +223 -0
  198. package/src/integrations/sanitizeError.ts +287 -0
  199. package/src/levels.ts +35 -0
  200. package/src/metadata.ts +52 -0
  201. package/src/privacy.ts +389 -0
  202. package/src/specs/FileSink.nitro.ts +144 -0
  203. package/src/specs/NativeConsoleSink.nitro.ts +21 -0
  204. package/src/types.ts +45 -0
  205. package/src/utf8.ts +39 -0
@@ -0,0 +1,296 @@
1
+ import Foundation
2
+
3
+ /// Creating log files and directories the way a file full of patient data has
4
+ /// to be created.
5
+ ///
6
+ /// Every artifact the writer produces goes through here — the directory, the
7
+ /// active file, each archive, the gzip temporary, the recovery staging file —
8
+ /// because the protections are only worth anything if they hold on all of
9
+ /// them. An archive that inherits default permissions is exactly as readable
10
+ /// as the log it was rotated from.
11
+ ///
12
+ /// Three separate things are being asked for, and they fail independently:
13
+ ///
14
+ /// - **POSIX modes**, type-specific. A directory needs `0700`: owner read,
15
+ /// write, and *search*, since without the execute bit nothing inside it can
16
+ /// be reached by path. A file needs `0600` — an executable log file is a
17
+ /// contradiction, and `0700` on a file is the kind of thing that gets copied
18
+ /// into the next project.
19
+ /// - **Data protection**, `completeUntilFirstUserAuthentication`. Not
20
+ /// `complete`: the crash handler and the background flush both write while
21
+ /// the device may be locked, and a stricter class would turn those writes
22
+ /// into silent failures at exactly the moment the log matters. This class
23
+ /// still means the file is unreadable until the user has unlocked once
24
+ /// since boot.
25
+ /// - **Backup exclusion.** Logs must not ride an iCloud or iTunes backup off
26
+ /// the device.
27
+ ///
28
+ /// **Every mode is read back after it is set.** Asking for `0600` and assuming
29
+ /// it took is how a world-readable file inherited from a restored backup gets
30
+ /// treated as protected: `chmod` can fail, and `setAttributes` swallows the
31
+ /// reason. What matters is the mode that is on disk afterwards, so that is what
32
+ /// gets checked and what gets reported.
33
+ ///
34
+ /// Failures are reported rather than thrown. A log file that exists with the
35
+ /// wrong protection class is a problem the app should know about; a log file
36
+ /// that could not be created at all is a worse one.
37
+ internal enum LogSecureFile {
38
+
39
+ /// What could not be applied. Empty means everything held.
40
+ struct Shortfall: OptionSet {
41
+ let rawValue: Int
42
+ /// The data-protection class or backup exclusion did not stick.
43
+ static let protection = Shortfall(rawValue: 1 << 0)
44
+ /// The POSIX mode on disk is not what it should be — the artifact is
45
+ /// readable by more than its owner, or a directory is not traversable.
46
+ static let permissions = Shortfall(rawValue: 1 << 1)
47
+ }
48
+
49
+ static let directoryMode: Int = 0o700
50
+ static let fileMode: Int = 0o600
51
+
52
+ /// Creates `url` and every missing parent as owner-only directories.
53
+ ///
54
+ /// **A directory this call did not create is inspected, never changed.** The
55
+ /// log path is caller-supplied and nothing constrains it to a directory this
56
+ /// package owns — `<Documents>/app.log` is a perfectly ordinary thing to ask
57
+ /// for, and `Documents` belongs to the host app. Every one of the three
58
+ /// protections is a property of the *directory* and outlives this sink:
59
+ ///
60
+ /// - the backup exclusion is persistent and directory-wide, so claiming it
61
+ /// silently takes the app's whole `Documents` tree out of iCloud backup;
62
+ /// - the protection class is inherited by every file created in there
63
+ /// afterwards, including files this package never writes;
64
+ /// - even the mode is not ours. `0700` looks like harmless tightening, but it
65
+ /// strips group and other access from a directory that may be deliberately
66
+ /// shared — an app group, an extension, a helper process — and the app has
67
+ /// no way to know we did it.
68
+ ///
69
+ /// So a pre-existing directory keeps its own policy and a loose mode comes
70
+ /// back as `permissions`, which the writer surfaces as a `protection`
71
+ /// degradation for the app to act on. Report rather than mutate is the rule
72
+ /// the rest of this writer already follows, and the reason the app is told at
73
+ /// all: a world-readable `Logs` left behind by an upgrade or a restored
74
+ /// backup is real, it just is not ours to silently reconfigure.
75
+ ///
76
+ /// A directory this call *creates* is unambiguously ours and gets all three.
77
+ ///
78
+ /// **Ownership is decided by `mkdir` itself, not by a check before it.** A
79
+ /// `lstat` that says "nothing here" is a statement about the past by the time
80
+ /// the directory is created, and the gap between the two is long enough for
81
+ /// another process to put a directory — or a symlink to one — in the way.
82
+ /// Losing that race means claiming somebody else's directory, which is the
83
+ /// whole thing this is here to prevent. `mkdir` cannot be raced: it either
84
+ /// creates the leaf, in which case it is ours, or it returns `EEXIST`, in
85
+ /// which case it is not, and the kernel decides which with no window in
86
+ /// between. It does not follow symlinks either, so a link already at the path
87
+ /// is `EEXIST` and is inspected rather than claimed through.
88
+ @discardableResult
89
+ static func createDirectory(at url: URL) throws -> Shortfall {
90
+ // Parents first, so the leaf `mkdir` has somewhere to land. The same rule
91
+ // applies to them by construction: `withIntermediateDirectories` gives the
92
+ // mode only to directories it creates and leaves existing ones alone.
93
+ try FileManager.default.createDirectory(
94
+ at: url.deletingLastPathComponent(),
95
+ withIntermediateDirectories: true,
96
+ attributes: [.posixPermissions: directoryMode]
97
+ )
98
+
99
+ // Subject to the umask, which is why `secure` re-asserts the mode and reads
100
+ // it back rather than trusting this argument.
101
+ if mkdir(url.path, mode_t(directoryMode)) == 0 {
102
+ return secure(url, isDirectory: true)
103
+ }
104
+ let failure = errno
105
+ guard failure == EEXIST else { throw posixError(failure, at: url) }
106
+
107
+ // `EEXIST` says something is there, not that it is a directory: a regular
108
+ // file, a symlink to one, or a dangling link all answer the same way. That
109
+ // is a configuration error and has to stay one. Reporting it as a mere
110
+ // shortfall would leave the sink to fail on the open a moment later, and a
111
+ // destination that accepts records into a path that can never hold them is
112
+ // worse than one that refuses to start. `stat`, not `lstat` — a symlink to
113
+ // a real directory is a usable directory, and is inspected like any other
114
+ // thing this call did not create.
115
+ var existing = stat()
116
+ if stat(url.path, &existing) != 0 {
117
+ // Its own errno, not a blanket `ENOTDIR`: a dangling link is `ENOENT`, an
118
+ // unreadable parent is `EACCES`, and failing storage is `EIO`. Those want
119
+ // different responses from whoever reads the log about the log.
120
+ let cause = errno
121
+ throw posixError(cause, at: url)
122
+ }
123
+ guard (existing.st_mode & S_IFMT) == S_IFDIR else {
124
+ throw posixError(ENOTDIR, at: url)
125
+ }
126
+ return inspect(url)
127
+ }
128
+
129
+ /// The path is safe to carry here: this error is consumed inside the writer —
130
+ /// `attemptReopen` discards it, and the Nitro adapter replaces every open
131
+ /// failure with a payload-free message before anything crosses into
132
+ /// JavaScript, precisely because a path is the kind of string that carries a
133
+ /// username.
134
+ private static func posixError(_ code: Int32, at url: URL) -> NSError {
135
+ NSError(domain: NSPOSIXErrorDomain, code: Int(code),
136
+ userInfo: [NSFilePathErrorKey: url.path])
137
+ }
138
+
139
+ /// What a directory we did not create falls short of, changing nothing.
140
+ ///
141
+ /// Only the mode is reportable. Whether the app excluded its own directory
142
+ /// from backup, or what protection class it chose, are its decisions and not
143
+ /// shortfalls — the writer is not entitled to an opinion on either.
144
+ private static func inspect(_ url: URL) -> Shortfall {
145
+ hasExpectedMode(url, isDirectory: true) ? [] : .permissions
146
+ }
147
+
148
+ /// Applies the protections to an artifact that already exists — an archive
149
+ /// just moved into place, a gzip staging file, a directory.
150
+ ///
151
+ /// Called after every `moveItem` as well as after every create: a move
152
+ /// preserves the source's attributes on the same volume, but says nothing
153
+ /// about a destination created by some other means, and the whole point is
154
+ /// that no artifact is exempt.
155
+ ///
156
+ /// Only ever called on an artifact this writer owns. `createDirectory`
157
+ /// decides that question for directories and does not call this when the
158
+ /// answer is no.
159
+ @discardableResult
160
+ static func secure(_ url: URL, isDirectory: Bool = false) -> Shortfall {
161
+ var shortfall = Shortfall()
162
+ let wanted = mode_t(isDirectory ? directoryMode : fileMode)
163
+
164
+ if chmod(url.path, wanted) != 0 { shortfall.insert(.permissions) }
165
+ if currentMode(of: url, followingLinks: isDirectory) != wanted {
166
+ shortfall.insert(.permissions)
167
+ }
168
+
169
+ shortfall.formUnion(protect(url, isDirectory: isDirectory))
170
+ return shortfall
171
+ }
172
+
173
+ /// The same, through an open descriptor.
174
+ ///
175
+ /// `fchmod` cannot be redirected: it acts on the file the caller already
176
+ /// holds, so nothing can swap a symlink in between deciding on a path and
177
+ /// changing its mode. Preferred wherever a descriptor is available, which is
178
+ /// everywhere the active log file is concerned.
179
+ ///
180
+ /// The protection class and backup exclusion have no descriptor-based
181
+ /// *setter*, so they still go through the path — but what they did is then
182
+ /// **read back through the descriptor**, never through the name.
183
+ ///
184
+ /// Comparing inodes after the fact is not enough, and that is the whole
185
+ /// reason this is written the long way. `lstat(path)` matching the descriptor
186
+ /// proves only that the name points here *now*. A rename away, a decoy
187
+ /// created under the name, the protections landing on the decoy, and a rename
188
+ /// back leaves every inode comparison agreeing while this app's file has no
189
+ /// protection at all. Asking the descriptor whether the xattr and the
190
+ /// protection class are actually on it has no such window: there is no name
191
+ /// in the question, so there is nothing to swap.
192
+ @discardableResult
193
+ static func secure(descriptor: Int32, at url: URL) -> Shortfall {
194
+ var shortfall = Shortfall()
195
+ let wanted = mode_t(fileMode)
196
+
197
+ if fchmod(descriptor, wanted) != 0 { shortfall.insert(.permissions) }
198
+ var opened = stat()
199
+ guard fstat(descriptor, &opened) == 0 else { return [.permissions, .protection] }
200
+ if (opened.st_mode & 0o7777) != wanted { shortfall.insert(.permissions) }
201
+
202
+ shortfall.formUnion(protect(url, isDirectory: false))
203
+ if !isProtected(descriptor: descriptor) { shortfall.insert(.protection) }
204
+ return shortfall
205
+ }
206
+
207
+ /// Whether `url` still names the inode described by `info`.
208
+ ///
209
+ /// A point-in-time answer, and only ever used as one: it tells the writer
210
+ /// whether the descriptor it just opened is still reachable under the path
211
+ /// that rotation and purge will address it by. It is *not* evidence about
212
+ /// protections — see `secure(descriptor:at:)` for why.
213
+ static func namesSameFile(_ url: URL, as info: stat) -> Bool {
214
+ var onDisk = stat()
215
+ guard lstat(url.path, &onDisk) == 0 else { return false }
216
+ return onDisk.st_dev == info.st_dev && onDisk.st_ino == info.st_ino
217
+ }
218
+
219
+ /// The extended attribute `isExcludedFromBackup` writes. Reading it back is
220
+ /// the only way to ask the *file* — rather than a path — whether exclusion
221
+ /// stuck.
222
+ private static let backupExclusionKey = "com.apple.metadata:com_apple_backup_excludeItem"
223
+
224
+ /// From `<sys/fcntl.h>`. Spelled out rather than imported: these macros are
225
+ /// not surfaced to Swift consistently across SDKs, and a missing symbol would
226
+ /// fail the build over a check that exists to be belt-and-braces.
227
+ private static let getProtectionClassCommand: Int32 = 63
228
+ private static let protectionClassCompleteUntilFirstAuth: Int32 = 3
229
+
230
+ /// Whether the protections are on the file this descriptor holds.
231
+ static func isProtected(descriptor: Int32) -> Bool {
232
+ guard fgetxattr(descriptor, backupExclusionKey, nil, 0, 0, 0) >= 0 else { return false }
233
+
234
+ #if os(iOS) || os(tvOS) || os(watchOS)
235
+ // Only iOS-family filesystems carry a protection class; asking elsewhere
236
+ // returns an error that would be read as a shortfall on every single file.
237
+ let assigned = fcntl(descriptor, getProtectionClassCommand)
238
+ guard assigned == protectionClassCompleteUntilFirstAuth else { return false }
239
+ #endif
240
+
241
+ return true
242
+ }
243
+
244
+ /// Data protection plus backup exclusion, reported rather than thrown.
245
+ private static func protect(_ url: URL, isDirectory: Bool) -> Shortfall {
246
+ var shortfall = Shortfall()
247
+
248
+ #if os(iOS) || os(tvOS) || os(watchOS)
249
+ // Directories carry a protection class too, and it is what newly created
250
+ // children inherit — setting it only on files leaves anything the system
251
+ // creates in there unprotected.
252
+ do {
253
+ try FileManager.default.setAttributes(
254
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
255
+ ofItemAtPath: url.path
256
+ )
257
+ } catch {
258
+ shortfall.insert(.protection)
259
+ }
260
+ #endif
261
+
262
+ var resource = URLResourceValues()
263
+ resource.isExcludedFromBackup = true
264
+ var mutable = url
265
+ do {
266
+ try mutable.setResourceValues(resource)
267
+ } catch {
268
+ shortfall.insert(.protection)
269
+ }
270
+
271
+ _ = isDirectory
272
+ return shortfall
273
+ }
274
+
275
+ /// The mode actually on disk.
276
+ ///
277
+ /// Following links has to match what `chmod` just did, or the check
278
+ /// contradicts the change. A log *directory* reached through a symlink is
279
+ /// supported — the registry resolves it, and `chmod` follows it — so
280
+ /// verifying that one with `lstat` would read the link's own `0755` and
281
+ /// report a shortfall against a target that is correctly `0700`. A log
282
+ /// *file*, by contrast, must never be a link at all, so there `lstat` is the
283
+ /// point: the mode of the link itself is exactly what we want to see.
284
+ static func currentMode(of url: URL, followingLinks: Bool) -> mode_t? {
285
+ var info = stat()
286
+ let ok = followingLinks ? stat(url.path, &info) == 0 : lstat(url.path, &info) == 0
287
+ guard ok else { return nil }
288
+ return info.st_mode & 0o7777
289
+ }
290
+
291
+ /// Whether `url`'s POSIX mode is exactly what this type should have.
292
+ static func hasExpectedMode(_ url: URL, isDirectory: Bool) -> Bool {
293
+ currentMode(of: url, followingLinks: isDirectory)
294
+ == mode_t(isDirectory ? directoryMode : fileMode)
295
+ }
296
+ }