salusc 0.3.4

The command line client for the salusd daemon
// Copyright (c) 2025 salus developers
//
// Licensed under the Apache License, Version 2.0
// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0> or the MIT
// license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
// option. All files in the project carrying such notice may not be copied,
// modified, or distributed except according to those terms.

//! Reading a `store` value from stdin, for the interactive and piped cases of
//! the `salusc store` subcommand.
//!
//! Interactive terminals only ever signal EOF on Ctrl-D, so a value typed at
//! the `Value: ` prompt must be read one line at a time (returning as soon as
//! Enter is pressed). Piped input has no such interactive expectation and may
//! legitimately be multi-line, so it is read to EOF instead. Both paths share
//! the same `max_bytes` enforcement and trailing-newline stripping.

use std::io::BufRead;

use anyhow::{Result, bail};
use tokio::io::{AsyncRead, AsyncReadExt};

/// Bail with the standard "exceeds max bytes" error if `buf` is longer than
/// `max_bytes`. Shared by the interactive and piped readers.
fn ensure_within_max(buf: &str, max_bytes: usize) -> Result<()> {
    if buf.len() > max_bytes {
        bail!(
            "stdin input exceeds {max_bytes} bytes; \
             increase with --max-value-bytes or SALUSC_STORE_MAX_VALUE_BYTES"
        );
    }
    Ok(())
}

/// Strip a single trailing `\n` or `\r\n` line ending, in place.
fn strip_trailing_newline(buf: &mut String) {
    if buf.ends_with('\n') {
        let _ = buf.pop();
        if buf.ends_with('\r') {
            let _ = buf.pop();
        }
    }
}

/// Read one line of interactive input from `reader`, blocking the calling
/// thread until Enter (or EOF) is seen.
///
/// Used only when stdin is a terminal, matching the blocking-read style of
/// `prompt_line` in `inter::mod`. Bounded to `max_bytes + 1` so a pasted
/// value longer than the limit is rejected instead of read unbounded into
/// memory.
pub(crate) fn read_interactive_value<R: BufRead>(reader: R, max_bytes: usize) -> Result<String> {
    let mut buf = String::new();
    let _ = reader
        .take((max_bytes as u64).saturating_add(1))
        .read_line(&mut buf)?;
    ensure_within_max(&buf, max_bytes)?;
    strip_trailing_newline(&mut buf);
    Ok(buf)
}

/// Read piped/non-interactive input to EOF (or `max_bytes + 1`, whichever
/// comes first) — for `echo secret | salusc store key`-style usage.
pub(crate) async fn read_piped_value<R: AsyncRead + Unpin>(
    reader: R,
    max_bytes: usize,
) -> Result<String> {
    let mut buf = String::new();
    let _ = reader
        .take((max_bytes as u64).saturating_add(1))
        .read_to_string(&mut buf)
        .await?;
    ensure_within_max(&buf, max_bytes)?;
    strip_trailing_newline(&mut buf);
    Ok(buf)
}

#[cfg(test)]
mod test {
    use anyhow::{Result, bail};

    use super::{read_interactive_value, read_piped_value, strip_trailing_newline};

    #[test]
    fn strip_trailing_newline_handles_lf() {
        let mut buf = String::from("abc\n");
        strip_trailing_newline(&mut buf);
        assert_eq!(buf, "abc");
    }

    #[test]
    fn strip_trailing_newline_handles_crlf() {
        let mut buf = String::from("abc\r\n");
        strip_trailing_newline(&mut buf);
        assert_eq!(buf, "abc");
    }

    #[test]
    fn strip_trailing_newline_handles_none() {
        let mut buf = String::from("abc");
        strip_trailing_newline(&mut buf);
        assert_eq!(buf, "abc");
    }

    #[test]
    fn read_interactive_value_strips_lf() -> Result<()> {
        let value = read_interactive_value(b"hello\n".as_slice(), 1024)?;
        assert_eq!(value, "hello");
        Ok(())
    }

    #[test]
    fn read_interactive_value_strips_crlf() -> Result<()> {
        let value = read_interactive_value(b"hello\r\n".as_slice(), 1024)?;
        assert_eq!(value, "hello");
        Ok(())
    }

    #[test]
    fn read_interactive_value_rejects_oversized_input() -> Result<()> {
        match read_interactive_value(b"toolong\n".as_slice(), 3) {
            Ok(value) => bail!("expected an error, got Ok({value:?})"),
            Err(error) => {
                let message = error.to_string();
                if !message.contains("exceeds") {
                    bail!("unexpected error message: {message}");
                }
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn read_piped_value_supports_multiline_eof_terminated_input() -> Result<()> {
        // No trailing newline — the pipe's EOF terminates the read.
        let value = read_piped_value(b"line one\nline two".as_slice(), 1024).await?;
        assert_eq!(value, "line one\nline two");
        Ok(())
    }

    #[tokio::test]
    async fn read_piped_value_strips_trailing_newline() -> Result<()> {
        let value = read_piped_value(b"secret\n".as_slice(), 1024).await?;
        assert_eq!(value, "secret");
        Ok(())
    }

    #[tokio::test]
    async fn read_piped_value_rejects_oversized_input() -> Result<()> {
        match read_piped_value(b"toolong".as_slice(), 3).await {
            Ok(value) => bail!("expected an error, got Ok({value:?})"),
            Err(error) => {
                let message = error.to_string();
                if !message.contains("exceeds") {
                    bail!("unexpected error message: {message}");
                }
            }
        }
        Ok(())
    }
}