001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.client;
018
019import static org.apache.juneau.commons.utils.IoUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.util.*;
023import java.util.stream.*;
024
025import org.apache.juneau.http.*;
026
027/**
028 * Default {@link RestClientDebugFormatter} implementation — secure by default.
029 *
030 * <p>
031 * Request/response bodies are <b>never dumped by default</b>; dumping is a deliberate operator opt-in behind the
032 * {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment-variable master gate, mirroring
033 * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s server-side contract. See
034 * {@link #bodyScrubber(RestDebugBodyScrubber)} and {@link RestClientDebugFormatter#isBodyRenderable(String)} for the
035 * rest of the pipeline.
036 *
037 * @since 10.0.0
038 */
039public class BasicRestClientDebugFormatter implements RestClientDebugFormatter {
040
041   /** Default body capture cap, in bytes (8 KB). */
042   public static final int DEFAULT_BODY_CAP = 8 * 1024;
043
044   /**
045    * The environment variable that gates body dumping. Keep byte-identical to
046    * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart — the same operator-facing
047    * kill switch must protect both the server and client debug pipelines.
048    */
049   static final String ENV_ALLOW_DUMP_BODIES = "JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES";
050
051   /**
052    * The widened, formatter-local default set of header names whose values are masked. Starts from a copy of
053    * {@link RedactedHeaders#DEFAULT} and adds the credential-bearing headers the shared default omits.
054    *
055    * <p>
056    * Keep byte-identical to {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart.
057    */
058   static final Set<String> DEFAULT_REDACTED_HEADERS;
059   static {
060      var s = new LinkedHashSet<>(RedactedHeaders.DEFAULT);
061      s.add("X-Auth-Token");
062      s.add("X-Authorization");
063      s.add("WWW-Authenticate");
064      s.add("Referer");
065      s.add("Location");
066      DEFAULT_REDACTED_HEADERS = Collections.unmodifiableSet(s);
067   }
068
069   /**
070    * Cached resolution of the body-dump master gate. {@code null} means "not yet resolved"; resolution reads the
071    * environment exactly once. Not a system property, and there is no system-property fallback.
072    */
073   private static volatile Boolean allowDumpBodiesCache;
074
075   /** The set of header names whose values are masked. */
076   protected Set<String> redactedHeaders = DEFAULT_REDACTED_HEADERS;
077
078   /** Optional body scrubber applied to body text when the master gate is set. */
079   protected RestDebugBodyScrubber bodyScrubber;
080
081   /** The body capture cap in bytes. */
082   protected int bodyCap = DEFAULT_BODY_CAP;
083
084   /** Constructor. */
085   public BasicRestClientDebugFormatter() {
086      /* no state to initialize */
087   }
088
089   /**
090    * Overrides the redacted-header set.
091    *
092    * @param value The new set of header names to mask.
093    * @return This object.
094    */
095   public BasicRestClientDebugFormatter redactedHeaders(Collection<String> value) {
096      redactedHeaders = new LinkedHashSet<>(value);
097      return this;
098   }
099
100   /**
101    * Sets the body scrubber applied to body text when the {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} gate is set.
102    *
103    * <p>
104    * The scrubber only ever runs once dumping is already permitted by the gate; it chooses scrubbed-vs-raw body
105    * text. It is fail-closed: a throw or a <jk>null</jk> return yields a placeholder, never the raw body.
106    *
107    * @param value The scrubber, or <jk>null</jk> for none (the default, which dumps raw when gated).
108    * @return This object.
109    */
110   public BasicRestClientDebugFormatter bodyScrubber(RestDebugBodyScrubber value) {
111      bodyScrubber = value;
112      return this;
113   }
114
115   /**
116    * Overrides the body capture cap.
117    *
118    * @param value The new cap in bytes.
119    * @return This object.
120    */
121   public BasicRestClientDebugFormatter bodyCap(int value) {
122      bodyCap = value;
123      return this;
124   }
125
126   @Override /* RestClientDebugFormatter */
127   public int bodyCap() {
128      return bodyCap;
129   }
130
131   @Override /* RestClientDebugFormatter */
132   public String formatBasic(RestRequest req, RestResponse res) {
133      var uri = req.getUri();
134      var method = DebugTextSanitizer.sanitize(req.getMethod());
135      var uriStr = DebugTextSanitizer.sanitize(uri != null ? uri.toString() : "");
136      var sb = new StringBuilder();
137      // Prepend the resolved correlation id (same effective-id order as the emit stamp): the confirmed echoed id
138      // when present, else the sent id.  Both are already safe to interpolate (client-minted Uuid7 values are safe;
139      // a server-echoed value was sanitized server-side), so no second sanitizer pass is needed for this token.
140      var effectiveId = effectiveRequestId(req, res);
141      if (effectiveId != null)
142         sb.append("[requestId=").append(effectiveId).append("] ");
143      return sb
144         .append('[').append(res.getStatusCode()).append("] ")
145         .append("HTTP ").append(method).append(' ')
146         .append(uriStr)
147         .append(" (").append(req.getExecTime() != null ? req.getExecTime().toMillis() : 0).append("ms)")
148         .toString();
149   }
150
151   // The confirmed echoed id when present, else the sent id.
152   private static String effectiveRequestId(RestRequest req, RestResponse res) {
153      if (res != null && res.getRequestId() != null)
154         return res.getRequestId();
155      return req.getRequestId();
156   }
157
158   @Override /* RestClientDebugFormatter */
159   public String formatHeaders(RestRequest req, RestResponse res) {
160      var sb = new StringBuilder();
161
162      var reqLen = req.getCachedContentLength();
163      if (reqLen >= 0)
164         sb.append("\n\tRequest length: ").append(reqLen).append(" bytes");
165
166      sb.append("\n\tResponse code: ").append(res.getStatusCode());
167
168      var resLen = res.getCachedContentLength();
169      if (resLen >= 0)
170         sb.append("\n\tResponse length: ").append(resLen).append(" bytes");
171
172      var normalizedRedacted = normalizedRedactedSet();
173
174      if (!req.getResolvedHeaders().isEmpty()) {
175         sb.append("\n---Request Headers---");
176         for (var h : req.getResolvedHeaders())
177            appendHeaderLine(sb, h.name(), h.value(), normalizedRedacted);
178      }
179
180      if (!res.getHeaders().isEmpty()) {
181         sb.append("\n---Response Headers---");
182         for (var h : res.getHeaders())
183            appendHeaderLine(sb, h.name(), h.value(), normalizedRedacted);
184      }
185      return sb.toString();
186   }
187
188   private void appendHeaderLine(StringBuilder sb, String name, String value, Set<String> normalizedRedacted) {
189      // Order: mask (name-based, on the raw value) → escape (name, then whatever survived masking) → cap (none here).
190      var masked = isRedacted(name, normalizedRedacted) ? RedactedHeaders.REDACTED : value;
191      sb.append("\n\t").append(DebugTextSanitizer.sanitize(name)).append(": ").append(DebugTextSanitizer.sanitize(masked));
192   }
193
194   private Set<String> normalizedRedactedSet() {
195      return redactedHeaders.stream()
196         .filter(Objects::nonNull)
197         .map(BasicRestClientDebugFormatter::normalizeHeaderName)
198         .collect(Collectors.toCollection(HashSet::new));
199   }
200
201   private static boolean isRedacted(String name, Set<String> normalizedRedacted) {
202      return name != null && normalizedRedacted.contains(normalizeHeaderName(name));
203   }
204
205   /** Case-folds and strips {@code -}/{@code _} and whitespace so {@code X-Auth-Token}/{@code X_Auth_Token}/{@code XAuthToken} all match. */
206   private static String normalizeHeaderName(String name) {
207      var sb = new StringBuilder(name.length());
208      for (var i = 0; i < name.length(); i++) {
209         var c = name.charAt(i);
210         if (c == '-' || c == '_' || Character.isWhitespace(c))
211            continue;
212         sb.append(Character.toLowerCase(c));
213      }
214      return sb.toString();
215   }
216
217   @Override /* RestClientDebugFormatter */
218   public String formatBody(RestRequest req, RestResponse res) {
219      var sb = new StringBuilder();
220      // Content-Type/Content-Encoding are resolved independently per side, each from that side's own finalized,
221      // wire-order header list — never reused across sides (Risk 5).
222      var reqCt = header(req.getFirstHeader("Content-Type"));
223      var reqCe = header(req.getFirstHeader("Content-Encoding"));
224      var resCt = header(res.getFirstHeader("Content-Type"));
225      var resCe = header(res.getFirstHeader("Content-Encoding"));
226      appendBody(sb, "Request", req.getCachedContent(), req.getCachedContentLength(), reqCt, reqCe);
227      appendBody(sb, "Response", res.getCachedContent(), res.getCachedContentLength(), resCt, resCe);
228      return sb.toString();
229   }
230
231   private static String header(TransportHeader h) {
232      return h == null ? null : h.value();
233   }
234
235   private void appendBody(StringBuilder sb, String label, byte[] content, long totalLength, String contentType, String contentEncoding) {
236      if (content == null || content.length == 0)
237         return;
238      var byteCount = totalLength >= 0 ? totalLength : content.length;
239      var ctToken = contentTypeToken(contentType);
240
241      if (! isAllowDumpBodies()) {
242         appendPlaceholder(sb, label, byteCount, ctToken, "; set " + ENV_ALLOW_DUMP_BODIES + " to enable", "body suppressed");
243         return;
244      }
245
246      if (! isRenderable(contentType, contentEncoding)) {
247         appendPlaceholder(sb, label, byteCount, ctToken, "; binary/non-renderable content", "body not rendered");
248         return;
249      }
250
251      var raw = new String(content, UTF8);
252      String bodyText;
253      if (bodyScrubber != null) {
254         String scrubbed;
255         try {
256            scrubbed = bodyScrubber.scrub(contentType, raw);
257         } catch (Throwable t) {  // NOSONAR - fail closed on any scrubber failure; never re-leak via the exception.
258            scrubbed = null;
259         }
260         if (scrubbed == null) {
261            appendPlaceholder(sb, label, byteCount, ctToken, "; scrubber failed", "body suppressed");
262            return;
263         }
264         bodyText = scrubbed;
265      } else {
266         bodyText = raw;
267      }
268
269      sb.append("\n---").append(label).append(" Content---");
270      sb.append('\n').append(DebugTextSanitizer.sanitize(bodyText, charCap()));
271      var omitted = byteCount - content.length;
272      if (omitted > 0)
273         sb.append("\n\u2026[truncated ").append(omitted).append(" bytes]");
274   }
275
276   private void appendPlaceholder(StringBuilder sb, String label, long byteCount, String ctToken, String reason, String verb) {
277      sb.append("\n---").append(label).append(" Content---");
278      sb.append("\n[").append(verb).append(": ").append(byteCount).append(" bytes, ").append(ctToken).append(reason).append(']');
279   }
280
281   private String contentTypeToken(String contentType) {
282      if (ib(contentType))
283         return "unknown";
284      return DebugTextSanitizer.sanitize(contentType);
285   }
286
287   private boolean isRenderable(String contentType, String contentEncoding) {
288      if (! isIdentityEncoding(contentEncoding))
289         return false;
290      return isBodyRenderable(contentType);
291   }
292
293   private static boolean isIdentityEncoding(String contentEncoding) {
294      if (contentEncoding == null)
295         return true;
296      var e = contentEncoding.trim();
297      return e.isEmpty() || eqic(e, "identity");
298   }
299
300   /**
301    * Returns the character-length cap for a dumped body, derived from {@link #bodyCap()}.
302    *
303    * <p>
304    * Worst-case escaping expands one byte to a 6-character {@code \\uXXXX} sequence, so the sanitized character cap
305    * is {@code bodyCap() * 6} (clamped to {@code Integer.MAX_VALUE}). Capture already bounds the raw bytes; this
306    * second layer bounds a scrubber that returns an oversized string.
307    */
308   private int charCap() {
309      var cap = Math.max(bodyCap, 0) * 6L;
310      return cap > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) cap;
311   }
312
313   /**
314    * Returns whether body dumping is enabled by the {@code JUNEAU_REST_DEBUG_ALLOW_DUMP_BODIES} environment
315    * variable.
316    *
317    * <p>
318    * Resolved once from the environment and cached. Never reads a system property. Keep byte-identical to
319    * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart.
320    *
321    * @return <jk>true</jk> if body dumping is enabled.
322    */
323   static boolean isAllowDumpBodies() {
324      var v = allowDumpBodiesCache;
325      if (v == null) {
326         v = parseAllowDumpBodies(System.getenv(ENV_ALLOW_DUMP_BODIES));
327         allowDumpBodiesCache = v;
328      }
329      return v;
330   }
331
332   /**
333    * Parses a raw gate value with trim-then-parse truthy semantics.
334    *
335    * <p>
336    * The value is trimmed first; a non-empty trimmed value enables (returns <jk>true</jk>) except case-insensitive
337    * {@code false} or {@code 0}; {@code null}/empty/all-whitespace disables. This is a fail-safe kill switch, so
338    * ambiguous values resolve to disabled. Keep byte-identical to
339    * {@code org.apache.juneau.rest.server.logging.BasicRestDebugFormatter}'s counterpart.
340    *
341    * @param raw The raw environment value. Can be <jk>null</jk>.
342    * @return <jk>true</jk> if the value enables body dumping.
343    */
344   static boolean parseAllowDumpBodies(String raw) {
345      if (raw == null)
346         return false;
347      var t = raw.trim();
348      if (t.isEmpty())
349         return false;
350      return ! (eqic(t, "false") || eq(t, "0"));
351   }
352
353   /**
354    * Test-only seam for exercising both gate states without mutating the process environment.
355    *
356    * <p>
357    * Package-private and unreachable from application code, and never reads a system property. Production
358    * resolution stays env-only / read-once ({@link #isAllowDumpBodies()}).
359    *
360    * @param override {@code null} clears the cache so the next resolution re-reads the environment once; a
361    *    non-<jk>null</jk> value is cached directly as the test gate state.
362    */
363   static void resetAllowDumpBodiesForTest(Boolean override) {
364      allowDumpBodiesCache = override;
365   }
366}