qubit-redact 0.5.0

Rule-driven redaction for fields, diagnostics, HTTP data, and Rust domain objects
Documentation
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================
//! Byte-bounded accumulation for masked values.

use std::fmt;

/// Accumulates at most a fixed number of UTF-8 bytes.
pub(in crate::policy) struct BoundedMaskWriter {
    /// Retained masked prefix.
    output: String,
    /// Maximum retained masked bytes supplied by the transaction caller.
    max_bytes: usize,
    /// Whether any complete masked bytes could not be retained.
    truncated: bool,
}

impl BoundedMaskWriter {
    /// Creates an empty bounded mask writer.
    ///
    /// # Parameters
    ///
    /// * `max_bytes` - Maximum retained UTF-8 bytes.
    ///
    /// # Returns
    ///
    /// An empty writer that grows only for retained masked bytes.
    #[must_use]
    pub(in crate::policy) fn new(max_bytes: usize) -> Self {
        Self {
            output: String::new(),
            max_bytes,
            truncated: false,
        }
    }

    /// Returns the retained masked prefix.
    ///
    /// # Returns
    ///
    /// The owned masked UTF-8 prefix within the configured byte budget.
    pub(in crate::policy) fn finish(self) -> (String, bool) {
        (self.output, self.truncated)
    }
}

impl fmt::Write for BoundedMaskWriter {
    /// Appends the longest UTF-8 prefix that fits the remaining budget.
    ///
    /// # Parameters
    ///
    /// * `value` - Masked text to append within the remaining byte budget.
    ///
    /// # Returns
    ///
    /// `Ok(())` after retaining the longest complete UTF-8 prefix that fits.
    ///
    /// # Errors
    ///
    /// This bounded in-memory writer does not return a formatting error.
    fn write_str(&mut self, value: &str) -> fmt::Result {
        let remaining = self.max_bytes.saturating_sub(self.output.len());
        let mut end = value.len().min(remaining);
        while !value.is_char_boundary(end) {
            end -= 1;
        }
        self.output.push_str(&value[..end]);
        self.truncated |= end < value.len();
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use std::fmt::Write;

    use super::BoundedMaskWriter;

    #[test]
    fn test_writer_keeps_only_complete_utf8_prefixes_across_writes() {
        let mut writer = BoundedMaskWriter::new(5);

        writer.write_str("甲乙").expect("the bounded writer must never fail");
        writer.write_str("z").expect("the bounded writer must never fail");

        let (output, truncated) = writer.finish();
        assert_eq!(output, "甲z");
        assert!(truncated);
    }

    #[test]
    fn test_writer_marks_empty_budget_as_truncated_for_non_empty_input() {
        let mut writer = BoundedMaskWriter::new(0);

        writer.write_str("mask").expect("the bounded writer must never fail");

        let (output, truncated) = writer.finish();
        assert_eq!(output, "");
        assert!(truncated);
    }
}