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,186 @@
1
+ package com.margelo.nitro.nitrologger
2
+
3
+ /**
4
+ * Rotation and retention for one log file.
5
+ *
6
+ * Every field is clamped on the way in. These numbers arrive from JavaScript,
7
+ * where `NaN`, `Infinity` and `-1` are all ordinary values, and an unchecked
8
+ * `Double.toLong()` of any of them is silently wrong rather than loud — `NaN`
9
+ * becomes 0, which would turn "rotate at 10 MB" into "rotate on every write".
10
+ *
11
+ * The clamping rules are the iOS writer's, deliberately: a policy that behaves
12
+ * differently on the two platforms is a bug report nobody can reproduce.
13
+ */
14
+ data class LogRotationPolicy(
15
+ val maxFileSizeBytes: Long,
16
+ val maxArchivedFilesCount: Int,
17
+ val maxFileAgeSeconds: Double?,
18
+ val compressArchives: Boolean,
19
+ val maxArchiveAgeSeconds: Double?,
20
+ val maxTotalLogBytes: Long?
21
+ ) {
22
+ companion object {
23
+ /** Effectively "no limit" while still fitting in the types below. */
24
+ private const val BYTE_CEILING = 1_000_000_000_000_000L
25
+ private const val COUNT_CEILING = 10_000
26
+ private const val SECONDS_CEILING = 1e9
27
+
28
+ const val DEFAULT_MAX_FILE_SIZE_BYTES = 10_485_760L
29
+ const val DEFAULT_MAX_ARCHIVED_FILES = 5
30
+
31
+ fun of(
32
+ maxFileSizeBytes: Double = DEFAULT_MAX_FILE_SIZE_BYTES.toDouble(),
33
+ maxArchivedFilesCount: Double = DEFAULT_MAX_ARCHIVED_FILES.toDouble(),
34
+ maxFileAgeSeconds: Double? = null,
35
+ compressArchives: Boolean = false,
36
+ maxArchiveAgeSeconds: Double? = null,
37
+ maxTotalLogBytes: Double? = null
38
+ ): LogRotationPolicy = LogRotationPolicy(
39
+ maxFileSizeBytes = bytes(maxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_BYTES),
40
+ maxArchivedFilesCount = count(maxArchivedFilesCount, DEFAULT_MAX_ARCHIVED_FILES),
41
+ maxFileAgeSeconds = seconds(maxFileAgeSeconds),
42
+ compressArchives = compressArchives,
43
+ maxArchiveAgeSeconds = seconds(maxArchiveAgeSeconds),
44
+ maxTotalLogBytes = optionalBytes(maxTotalLogBytes)
45
+ )
46
+
47
+ /**
48
+ * `Infinity` clamps UP, to the ceiling. It reads as "never rotate", and
49
+ * folding it into the default — or into zero — would turn a request for no
50
+ * rotation into constant rotation. Only `NaN` and non-positive values fall
51
+ * back, because those carry no intent to honour.
52
+ */
53
+ private fun bytes(value: Double, fallback: Long): Long = when {
54
+ value.isNaN() || value < 1 -> fallback
55
+ value.isInfinite() -> BYTE_CEILING
56
+ else -> minOf(value, BYTE_CEILING.toDouble()).toLong()
57
+ }
58
+
59
+ /**
60
+ * Same rule as [bytes], and for a sharper reason: this limit is what
61
+ * pruning deletes down to.
62
+ *
63
+ * A literal zero is a real instruction — "keep no archives" — so it is
64
+ * honoured. `NaN` and negatives are not instructions at all, and folding
65
+ * them into zero would make one malformed number from JavaScript delete
66
+ * every rotated file on the next sweep. They fall back to the default
67
+ * instead: retaining five archives nobody asked for is recoverable, and
68
+ * deleting the lot is not.
69
+ */
70
+ private fun count(value: Double, fallback: Int): Int = when {
71
+ value.isNaN() || value < 0 -> fallback
72
+ value.isInfinite() -> COUNT_CEILING
73
+ else -> minOf(value, COUNT_CEILING.toDouble()).toInt()
74
+ }
75
+
76
+ /**
77
+ * Optional limits read `Infinity` as absence: "no age cap" and "no cap at
78
+ * all" are the same instruction, and null is how this type spells it.
79
+ */
80
+ private fun seconds(value: Double?): Double? {
81
+ if (value == null || !value.isFinite() || value <= 0) return null
82
+ return minOf(value, SECONDS_CEILING)
83
+ }
84
+
85
+ private fun optionalBytes(value: Double?): Long? {
86
+ if (value == null || !value.isFinite() || value < 1) return null
87
+ return minOf(value, BYTE_CEILING.toDouble()).toLong()
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Payload-free record of what has stopped working.
94
+ *
95
+ * A bitmask rather than messages on purpose: these cross into JavaScript and
96
+ * eventually into a log file, and a path or an errno string is exactly the kind
97
+ * of thing that carries a username in it.
98
+ */
99
+ object LogDegradation {
100
+ const val NONE = 0
101
+ const val ROTATION = 1 shl 0
102
+ const val GZIP = 1 shl 1
103
+ const val PRUNE = 1 shl 2
104
+ const val SIDECAR = 1 shl 3
105
+ const val PROTECTION = 1 shl 4
106
+ }
107
+
108
+ enum class LogRejectReason(val wire: String) {
109
+ FULL("full"),
110
+ STALE_GENERATION("staleGeneration"),
111
+ CLOSED("closed"),
112
+ FAILED("failed")
113
+ }
114
+
115
+ data class LogSinkStatus(
116
+ val queuedBytes: Long,
117
+ val lostBytes: Long,
118
+ val lostEntries: Long,
119
+ val degraded: Int
120
+ )
121
+
122
+ data class LogAppendResult(
123
+ val accepted: Boolean,
124
+ val rejectReason: LogRejectReason?,
125
+ val status: LogSinkStatus
126
+ )
127
+
128
+ /**
129
+ * Numbers arriving from JavaScript, which reach Kotlin as [Double].
130
+ *
131
+ * This lives here rather than in the Nitro adapter so the JUnit suite can
132
+ * reach it: the adapter needs a device, and this is the part worth testing.
133
+ */
134
+ object BridgeNumber {
135
+ /**
136
+ * [value] as an exact [Long], or null if it was never one.
137
+ *
138
+ * `Double.toLong()` saturates and truncates rather than failing, so every
139
+ * check has to happen before the conversion. NaN would become 0 — which then
140
+ * passes the writer's own checks whenever the batch is empty too — each
141
+ * infinity would become a bound, and 2.5 would become 2, an accepted batch
142
+ * carrying a count nobody sent. iOS refuses all of these via `Int(exactly:)`;
143
+ * this is the same refusal.
144
+ */
145
+ fun exactLong(value: Double): Long? {
146
+ if (!value.isFinite()) return null
147
+ if (value != Math.floor(value)) return null
148
+ // Integral is not sufficient: 1e19 has no fractional part and still does
149
+ // not fit, and `toLong()` would clamp it to Long.MAX_VALUE.
150
+ //
151
+ // The two bounds are deliberately not symmetric, because the two constants
152
+ // are not. Long.MIN_VALUE is -2^63, exactly representable as a Double, so
153
+ // it converts and the comparison is inclusive. Long.MAX_VALUE is 2^63 - 1,
154
+ // which is NOT representable — `Long.MAX_VALUE.toDouble()` rounds UP to
155
+ // 2^63. An inclusive upper bound would therefore admit 2^63 itself and let
156
+ // `toLong()` saturate it to 2^63 - 1: a silently different number, which is
157
+ // the precise failure this function exists to prevent. Hence `>=`.
158
+ if (value < Long.MIN_VALUE.toDouble() || value >= Long.MAX_VALUE.toDouble()) return null
159
+ return value.toLong()
160
+ }
161
+ }
162
+
163
+ data class LogFlushOutcome(
164
+ val durable: Boolean,
165
+ val timedOut: Boolean,
166
+ val pendingBytes: Long,
167
+ val status: LogSinkStatus
168
+ )
169
+
170
+ data class LogClearOutcome(
171
+ val deletedCount: Int,
172
+ val failedPaths: List<String>,
173
+ /** Every artifact is gone. This is the compliance question. */
174
+ val durable: Boolean,
175
+ /**
176
+ * The writer has a usable file again. Separate from [durable] because a
177
+ * complete deletion can still be followed by a failed reopen, and a caller
178
+ * that resumes on [durable] alone writes into a writer with nowhere to put
179
+ * anything.
180
+ */
181
+ val rebound: Boolean = false
182
+ )
183
+
184
+ class LogWriterException(val kind: Kind, message: String) : Exception(message) {
185
+ enum class Kind { OPEN_FAILED, CONFIG_CONFLICT, SYMLINK_ESCAPE, STILL_CLOSING }
186
+ }
@@ -0,0 +1,80 @@
1
+ package com.margelo.nitro.nitrologger
2
+
3
+ import java.io.File
4
+
5
+ /**
6
+ * Creating and locking down the files this package writes.
7
+ *
8
+ * The iOS twin has more to do here: a data-protection class and a backup
9
+ * exclusion attribute, both applied per artifact. Android's equivalents are
10
+ * structural rather than per file — the log directory lives under
11
+ * `noBackupFilesDir`, which keeps it out of Auto Backup, and app-private
12
+ * storage is already unreadable by other apps — so what is left for this file
13
+ * is the second layer: owner-only modes on every artifact, and a refusal to
14
+ * follow a symlink out of the directory the registry believes it owns.
15
+ *
16
+ * Every function reports whether it fully succeeded rather than throwing.
17
+ * Failing to tighten a mode is worth recording as a `protection` degradation;
18
+ * it is not worth refusing to log over.
19
+ */
20
+ object LogSecureFile {
21
+ /**
22
+ * Creates the directory chain and restricts each level to its owner.
23
+ *
24
+ * Each level, not just the leaf: a 0700 directory inside a world-readable one
25
+ * still hides its contents, but the intermediate `logs` name and the
26
+ * timestamps on it are visible, and there is no reason to leak them.
27
+ */
28
+ fun createDirectory(directory: File, platform: PlatformIo): Boolean {
29
+ var ok = true
30
+
31
+ if (!directory.isDirectory) {
32
+ // Walk down from the highest missing ancestor so each level can be
33
+ // tightened as it appears. `mkdirs()` alone creates the whole chain with
34
+ // default modes and gives no hook in between.
35
+ val missing = generateSequence(directory) { it.parentFile }
36
+ .takeWhile { !it.isDirectory }
37
+ .toList()
38
+ .asReversed()
39
+
40
+ for (level in missing) {
41
+ if (!level.mkdir() && !level.isDirectory) return false
42
+ if (!platform.restrictToOwner(level, isDirectory = true)) ok = false
43
+ }
44
+ } else if (!platform.restrictToOwner(directory, isDirectory = true)) {
45
+ ok = false
46
+ }
47
+
48
+ return ok && directory.isDirectory
49
+ }
50
+
51
+ /** Restricts one artifact to its owner. */
52
+ fun secure(file: File, platform: PlatformIo): Boolean {
53
+ if (!file.exists()) return true
54
+ return platform.restrictToOwner(file, isDirectory = file.isDirectory)
55
+ }
56
+
57
+ /**
58
+ * Whether the final component of a path is a symlink.
59
+ *
60
+ * Following one would write the app's log wherever the link points — a path
61
+ * the caller never named and the purge would never clean — and would let two
62
+ * different registry keys resolve to the same file. This is the `lstat` +
63
+ * `S_IFLNK` check the iOS writer does, and it deliberately asks about the
64
+ * leaf alone.
65
+ *
66
+ * **Only the leaf.** A symlinked *ancestor* is not a problem to refuse over:
67
+ * the registry resolves the directory canonically before the writer ever
68
+ * sees the path, precisely so that two names for one directory collapse to
69
+ * one key. Comparing `canonicalFile` to `absoluteFile` would fold that
70
+ * intended resolution into the check and refuse to open anything under a
71
+ * symlinked parent at all — which on macOS is every `createTempFile`
72
+ * directory, since `/var` is a link to `/private/var`.
73
+ *
74
+ * Delegated to [PlatformIo] rather than answered here: `Files.isSymbolicLink`
75
+ * is API 26 and this library supports 24, so Android answers it with
76
+ * `Os.lstat` instead.
77
+ */
78
+ fun isSymbolicLink(file: File, platform: PlatformIo): Boolean =
79
+ platform.isSymbolicLink(file)
80
+ }
@@ -0,0 +1,420 @@
1
+ package com.margelo.nitro.nitrologger
2
+
3
+ import java.io.File
4
+ import java.util.concurrent.TimeUnit
5
+ import java.util.concurrent.locks.ReentrantLock
6
+ import kotlin.concurrent.withLock
7
+
8
+ /**
9
+ * One writer per file, no matter how many destinations point at it.
10
+ *
11
+ * Two streams appending to the same path from different threads interleave
12
+ * mid-record, and two rotation schedules racing over the same file archive each
13
+ * other's fresh output. The registry makes that impossible by construction:
14
+ * everything that resolves to the same file gets the same [LogFileWriter].
15
+ *
16
+ * **The key is the canonical path.** `logs/app.log`, `logs/../logs/app.log`,
17
+ * and a `logs` symlink into the real directory are the same file and must land
18
+ * on the same entry — comparing the strings the caller passed in would hand out
19
+ * two writers for one file, which is exactly the collision the registry exists
20
+ * to stop.
21
+ */
22
+ class LogWriterRegistry {
23
+ companion object {
24
+ val shared = LogWriterRegistry()
25
+
26
+ /**
27
+ * How long an acquire will wait for a previous writer on the same path to
28
+ * finish shutting down before giving up.
29
+ */
30
+ const val CLOSE_WAIT_MS = 5_000L
31
+
32
+ /**
33
+ * Turns a caller-supplied path into the canonical file it names.
34
+ *
35
+ * The parent directory is created before resolution, because canonical
36
+ * resolution answers only for things that exist — resolving first would
37
+ * fall back to the literal string on a fresh install and hand out a key
38
+ * that stops matching the moment the directory appears.
39
+ */
40
+ @Throws(LogWriterException::class)
41
+ fun resolve(path: String, platform: PlatformIo): Resolved {
42
+ if (path.isEmpty()) {
43
+ throw LogWriterException(LogWriterException.Kind.OPEN_FAILED, "empty path")
44
+ }
45
+ // Collapse `.` and `..` lexically before anything looks at the parent.
46
+ // `logs/sub/../app.log` names a file in `logs`, but taken literally its
47
+ // parent is `logs/sub/..` — a directory that only exists if `sub` does,
48
+ // so creating it fails and the open dies on a path that was perfectly
49
+ // valid. iOS gets this from `standardizedFileURL`.
50
+ val requested = lexicallyNormalized(File(path).absoluteFile)
51
+ val name = requested.name
52
+ if (name.isEmpty() || name == "." || name == "..") {
53
+ throw LogWriterException(
54
+ LogWriterException.Kind.OPEN_FAILED,
55
+ "path does not name a file"
56
+ )
57
+ }
58
+ val directory = requested.parentFile
59
+ ?: throw LogWriterException(
60
+ LogWriterException.Kind.OPEN_FAILED,
61
+ "path has no directory"
62
+ )
63
+
64
+ if (!LogSecureFile.createDirectory(directory, platform) && !directory.isDirectory) {
65
+ throw LogWriterException(
66
+ LogWriterException.Kind.OPEN_FAILED,
67
+ "could not create the log directory"
68
+ )
69
+ }
70
+
71
+ val canonicalDirectory = try {
72
+ directory.canonicalFile
73
+ } catch (_: Exception) {
74
+ throw LogWriterException(
75
+ LogWriterException.Kind.OPEN_FAILED,
76
+ "could not resolve the log directory"
77
+ )
78
+ }
79
+
80
+ // The log file itself must not be a symlink, even though the directory
81
+ // above it was resolved through one deliberately.
82
+ val candidate = File(canonicalDirectory, name)
83
+ if (LogSecureFile.isSymbolicLink(candidate, platform)) {
84
+ throw LogWriterException(
85
+ LogWriterException.Kind.SYMLINK_ESCAPE,
86
+ "the log file is a symbolic link"
87
+ )
88
+ }
89
+
90
+ return Resolved(candidate, candidate.absolutePath)
91
+ }
92
+
93
+ /**
94
+ * Collapses `.` and `..` in an absolute path, textually.
95
+ *
96
+ * `Path.normalize()` would be the obvious call and was the original one, but
97
+ * `java.nio.file` arrived in API 26 and this library supports 24. A missing
98
+ * class there raises `NoClassDefFoundError` — an `Error`, which passes
99
+ * straight through `catch (Exception)` — so on an older device the failure
100
+ * would not have been a fallback but an unhandled throw out of `open()`.
101
+ *
102
+ * Purely lexical, like the original: it does not touch the filesystem, so
103
+ * `a/symlink/../b` collapses to `a/b` whether or not that is where the link
104
+ * pointed. That is the same trade `standardizedFileURL` makes on iOS, and it
105
+ * is safe here because the *directory* is canonicalised for real straight
106
+ * afterwards, and the leaf is `lstat`-checked.
107
+ */
108
+ private fun lexicallyNormalized(file: File): File {
109
+ val separator = File.separatorChar
110
+ val stack = ArrayList<String>()
111
+ for (part in file.absolutePath.split(separator)) {
112
+ when (part) {
113
+ "", "." -> Unit
114
+ ".." -> if (stack.isNotEmpty()) stack.removeAt(stack.size - 1)
115
+ else -> stack.add(part)
116
+ }
117
+ }
118
+ return File(separator + stack.joinToString(separator.toString()))
119
+ }
120
+
121
+ /** A registry with no shared state, so tests do not leak writers. */
122
+ fun isolated() = LogWriterRegistry()
123
+ }
124
+
125
+ data class Resolved(val file: File, val canonicalPath: String)
126
+
127
+ /**
128
+ * A lock with a condition, because acquisition sometimes has to *wait* — see
129
+ * [closing].
130
+ */
131
+ private val lock = ReentrantLock()
132
+ private val pathFreed = lock.newCondition()
133
+ private val writers = HashMap<String, LogFileWriter>()
134
+
135
+ /**
136
+ * Paths whose writer has been evicted but is still draining and closing.
137
+ *
138
+ * Eviction and close cannot be one atomic step: closing waits on the write
139
+ * executor, and holding the registry lock across that would stall every other
140
+ * file. But between the two, the map has no entry for the path — so an
141
+ * acquire arriving in that window would build a *second* writer while the
142
+ * first still has accepted batches queued, giving one file two executors and
143
+ * two rotation schedules. That is the exact collision this registry exists to
144
+ * prevent, so acquisition waits out the close instead.
145
+ *
146
+ * Counted rather than a set: it costs nothing and stops a stray double
147
+ * release from clearing a marker another close still needs.
148
+ */
149
+ private val closing = HashMap<String, Int>()
150
+ private var nextHandleId = 1L
151
+
152
+ /**
153
+ * Acquires a handle on the writer for [path], creating it if needed.
154
+ *
155
+ * Acquisition happens entirely under the lock, so two runtimes opening the
156
+ * same file concurrently cannot both construct a writer and have one silently
157
+ * replace the other.
158
+ */
159
+ @Throws(LogWriterException::class)
160
+ fun acquire(
161
+ path: String,
162
+ policy: LogRotationPolicy,
163
+ lineFramed: Boolean,
164
+ /**
165
+ * Required, with no default on purpose — see [LogFileWriter.open]. Falling
166
+ * back to [PlatformIo.Jvm] on Android would put `java.nio.file` back on the
167
+ * open path, where it does not exist below API 26.
168
+ */
169
+ platform: PlatformIo,
170
+ rawWrite: LogFileWriter.RawWrite? = null,
171
+ compressor: LogFileWriter.Compressor? = null,
172
+ clock: (() -> Long)? = null,
173
+ monotonic: (() -> Long)? = null
174
+ ): LogFileHandle {
175
+ val resolved = resolve(path, platform)
176
+
177
+ lock.withLock {
178
+ // Wait out a close still in progress on this path — but not forever. The
179
+ // claim is cleared by the writer's own executor rather than by whoever
180
+ // called close, so wedged storage means it may never clear at all.
181
+ // Failing the open is the fail-closed answer: one writer per file is the
182
+ // invariant worth keeping, and a caller that cannot have it should be
183
+ // told so rather than handed a second one.
184
+ var budget = CLOSE_WAIT_MS
185
+ while (closing.containsKey(resolved.canonicalPath)) {
186
+ if (budget <= 0) {
187
+ throw LogWriterException(
188
+ LogWriterException.Kind.STILL_CLOSING,
189
+ "a previous writer for this path is still closing"
190
+ )
191
+ }
192
+ val waited = try {
193
+ pathFreed.awaitNanos(TimeUnit.MILLISECONDS.toNanos(budget))
194
+ } catch (_: InterruptedException) {
195
+ // Same answer as running out of budget, and for the same reason: one
196
+ // writer per file is the invariant, and a caller that cannot be given
197
+ // it is told so rather than handed a second one.
198
+ Thread.currentThread().interrupt()
199
+ throw LogWriterException(
200
+ LogWriterException.Kind.STILL_CLOSING,
201
+ "interrupted while a previous writer for this path was closing"
202
+ )
203
+ }
204
+ budget = TimeUnit.NANOSECONDS.toMillis(waited)
205
+ }
206
+
207
+ val existing = writers[resolved.canonicalPath]
208
+ val writer: LogFileWriter
209
+ if (existing != null && !existing.isClosed) {
210
+ // A second destination on the same file must agree about how that file
211
+ // is written. Silently honouring the first caller's rotation policy
212
+ // would give the second one a file that behaves nothing like what it
213
+ // asked for, and silently honouring the last would change it under the
214
+ // first.
215
+ if (existing.policy != policy || existing.lineFramed != lineFramed) {
216
+ throw LogWriterException(
217
+ LogWriterException.Kind.CONFIG_CONFLICT,
218
+ "this file is already open with a different configuration"
219
+ )
220
+ }
221
+ writer = existing
222
+ } else {
223
+ writer = LogFileWriter.open(
224
+ file = resolved.file,
225
+ canonicalPath = resolved.canonicalPath,
226
+ policy = policy,
227
+ lineFramed = lineFramed,
228
+ platform = platform,
229
+ rawWrite = rawWrite,
230
+ compressor = compressor,
231
+ clock = clock,
232
+ monotonic = monotonic
233
+ )
234
+ writers[resolved.canonicalPath] = writer
235
+ }
236
+
237
+ writer.retain()
238
+ val id = nextHandleId
239
+ nextHandleId += 1
240
+ return LogFileHandle(id, writer, this)
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Drops one handle's claim, closing and evicting the writer at zero.
246
+ */
247
+ internal fun release(writer: LogFileWriter, handleId: Long, deadlineMs: Double) {
248
+ val path = writer.canonicalPath
249
+
250
+ val shouldClose = lock.withLock {
251
+ val remaining = writer.releaseOne()
252
+ if (remaining > 0) return@withLock false
253
+ if (writers[path] === writer) writers.remove(path)
254
+ // Claim the path for the duration of the close. An acquire arriving now
255
+ // waits rather than building a rival writer over a file this one is still
256
+ // draining.
257
+ closing[path] = (closing[path] ?: 0) + 1
258
+ true
259
+ }
260
+ if (!shouldClose) return
261
+
262
+ // The claim is dropped by the writer's own executor, not when this call
263
+ // stops waiting. A close that hits its deadline leaves work still
264
+ // executing; releasing the path then would let a replacement writer open
265
+ // the same file underneath it.
266
+ writer.close(handleId, deadlineMs) { finishClosing(path) }
267
+ }
268
+
269
+ private fun finishClosing(path: String) {
270
+ lock.withLock {
271
+ val outstanding = closing[path] ?: 0
272
+ if (outstanding > 1) closing[path] = outstanding - 1 else closing.remove(path)
273
+ pathFreed.signalAll()
274
+ }
275
+ }
276
+
277
+ val liveWriterCountForTesting: Int get() = lock.withLock { writers.size }
278
+ val closingCountForTesting: Int get() = lock.withLock { closing.size }
279
+ }
280
+
281
+ /**
282
+ * One destination's claim on a writer.
283
+ *
284
+ * The generation is the fence. A purge bumps the writer's generation; a handle
285
+ * that has not rebound to the new one is refused with `staleGeneration` rather
286
+ * than being allowed to write pre-purge data into the fresh file.
287
+ */
288
+ class LogFileHandle internal constructor(
289
+ val id: Long,
290
+ private val writer: LogFileWriter,
291
+ private val registry: LogWriterRegistry
292
+ ) {
293
+ private enum class State { ACTIVE, CLOSING, CLOSED }
294
+
295
+ private val lock = ReentrantLock()
296
+ private val purgeFinished = lock.newCondition()
297
+ private var generation = writer.currentGeneration
298
+ private var state = State.ACTIVE
299
+ /** A purge is running on this handle; close has to wait it out. */
300
+ private var purging = false
301
+
302
+ val filePath: String get() = writer.file.absolutePath
303
+
304
+ /**
305
+ * Whether this handle may still speak for the writer.
306
+ *
307
+ * Every entry point below is gated on it, and on ACTIVE specifically rather
308
+ * than "not yet CLOSED". A handle in CLOSING has already had its flush
309
+ * barrier enqueued by [close]; letting it submit more work, or report a
310
+ * status it will not be around to make true, is how a released destination
311
+ * ends up observing — or writing to — a writer that another handle now owns.
312
+ */
313
+ private val isLive: Boolean get() = lock.withLock { state == State.ACTIVE }
314
+
315
+ private fun inertStatus() = LogSinkStatus(0, 0, 0, 0)
316
+
317
+ fun appendBatch(batch: String, entryCount: Long): LogAppendResult = lock.withLock {
318
+ if (state != State.ACTIVE) {
319
+ return LogAppendResult(false, LogRejectReason.CLOSED, inertStatus())
320
+ }
321
+ writer.append(id, generation, batch, entryCount)
322
+ }
323
+
324
+ fun status(): LogSinkStatus {
325
+ if (!isLive) return inertStatus()
326
+ return writer.status(id)
327
+ }
328
+
329
+ fun flush(deadlineMs: Double): LogFlushOutcome {
330
+ // Not `durable = true`. A released handle flushed nothing, and saying
331
+ // otherwise invites the caller to treat its pending records as safe.
332
+ if (!isLive) return LogFlushOutcome(false, false, 0, inertStatus())
333
+ return writer.flush(id, deadlineMs)
334
+ }
335
+
336
+ fun logFilePaths(): List<String> {
337
+ if (!isLive) return emptyList()
338
+ return writer.logFilePaths()
339
+ }
340
+
341
+ /**
342
+ * Purges, then rebinds only if the writer really came back.
343
+ *
344
+ * `rebound` is a fact about THIS handle, not about the writer: if this handle
345
+ * is closing, or the purge did not end with a usable file, the caller must
346
+ * stay fenced even though the deletion itself may have been perfectly
347
+ * durable.
348
+ */
349
+ fun clearLogs(deadlineMs: Double): LogClearOutcome {
350
+ lock.lock()
351
+ if (state != State.ACTIVE || purging) {
352
+ lock.unlock()
353
+ return LogClearOutcome(0, listOf(filePath), durable = false)
354
+ }
355
+ purging = true
356
+ lock.unlock()
357
+
358
+ val result = try {
359
+ writer.clearLogs(deadlineMs)
360
+ } finally {
361
+ lock.withLock {
362
+ purging = false
363
+ purgeFinished.signalAll()
364
+ }
365
+ }
366
+
367
+ lock.withLock {
368
+ var outcome = result.first
369
+ if (state == State.ACTIVE && outcome.durable && outcome.rebound) {
370
+ generation = result.second
371
+ } else {
372
+ outcome = outcome.copy(rebound = false)
373
+ }
374
+ return outcome
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Closes this handle, waiting out any purge still running on it.
380
+ *
381
+ * One budget across all three waits. Giving each the full deadline means a
382
+ * caller asking for 200 ms can wait 600.
383
+ */
384
+ fun close(deadlineMs: Double): LogFlushOutcome {
385
+ // Monotonic, not `currentTimeMillis`. This budget spans three waits, and a
386
+ // device clock that steps backward part-way through — an NTP correction, a
387
+ // user changing the date — would hand the flush and teardown far more than
388
+ // the caller asked for. On the crash path that is the difference between a
389
+ // bounded close and an ANR.
390
+ val expiry = monotonicMs() + LogFileWriter.clampDeadline(deadlineMs)
391
+
392
+ lock.lock()
393
+ if (state != State.ACTIVE) {
394
+ lock.unlock()
395
+ return LogFlushOutcome(false, false, 0, inertStatus())
396
+ }
397
+ state = State.CLOSING
398
+ while (purging) {
399
+ val left = maxOf(0L, expiry - monotonicMs())
400
+ if (left <= 0) break
401
+ try {
402
+ if (!purgeFinished.await(left, TimeUnit.MILLISECONDS)) break
403
+ } catch (_: InterruptedException) {
404
+ Thread.currentThread().interrupt()
405
+ break
406
+ }
407
+ }
408
+ lock.unlock()
409
+
410
+ val outcome = writer.flush(id, remaining(expiry))
411
+ registry.release(writer, id, remaining(expiry))
412
+ lock.withLock { state = State.CLOSED }
413
+ return outcome
414
+ }
415
+
416
+ private fun remaining(expiry: Long): Double =
417
+ maxOf(0L, expiry - monotonicMs()).toDouble()
418
+
419
+ private fun monotonicMs(): Long = System.nanoTime() / 1_000_000L
420
+ }