Skip to content

Commit e120e8d

Browse files
committed
fix: strip NUL bytes from history commands (closes #3589)
External tools (e.g. AI agents) can mis-escape values and write raw NUL bytes into the shell history. NUL bytes break downstream consumers that expect valid UTF-8 text — search output, pipes, messagepack serialisation, and so on. Rather than reject the entire otherwise-useful entry, strip the NUL bytes at the point where new history entries are constructed (History::new), so clean and dirty paths alike are handled. Adds a sanitize_command helper with a fast-path for the common case (no NUL bytes present) and a test for both the stripped and clean paths.
1 parent 95570e5 commit e120e8d

1 file changed

Lines changed: 44 additions & 1 deletion

File tree

crates/atuin-client/src/history.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ impl History {
126126
})
127127
}
128128

129+
/// Strip NUL bytes from a command string.
130+
///
131+
/// NUL bytes can enter the history when an external tool (e.g. an AI agent
132+
/// that mis-escapes a value like `start=\0` instead of `start=0`) writes a
133+
/// raw NUL byte into the command. NUL bytes break downstream consumers
134+
/// that expect valid UTF-8 text — search output, pipes, messagepack
135+
/// serialization, etc. Rather than reject the whole entry, we silently
136+
/// strip the offending bytes so the (otherwise useful) command is still
137+
/// recorded. See atuinsh/atuin#3589.
138+
fn sanitize_command(command: String) -> String {
139+
if !command.contains('\0') {
140+
// fast path: most commands are clean
141+
command
142+
} else {
143+
command.replace('\0', "")
144+
}
145+
}
146+
129147
#[allow(clippy::too_many_arguments)]
130148
fn new(
131149
timestamp: OffsetDateTime,
@@ -152,7 +170,7 @@ impl History {
152170
Self {
153171
id: uuid_v7().as_simple().to_string().into(),
154172
timestamp,
155-
command,
173+
command: Self::sanitize_command(command),
156174
cwd,
157175
exit,
158176
duration,
@@ -698,6 +716,31 @@ mod tests {
698716
assert_eq!(history, deserialized);
699717
}
700718

719+
#[test]
720+
fn test_sanitize_strips_nul_bytes() {
721+
// Simulate the "start=\0" mis-escape reported in #3589
722+
let entry: History = History::capture()
723+
.timestamp(time::OffsetDateTime::now_utc())
724+
.command("start=\0 foo\0bar")
725+
.cwd("/")
726+
.build()
727+
.into();
728+
729+
assert_eq!(entry.command, "start= foobar");
730+
}
731+
732+
#[test]
733+
fn test_sanitize_preserves_clean_command() {
734+
let entry: History = History::capture()
735+
.timestamp(time::OffsetDateTime::now_utc())
736+
.command("git status")
737+
.cwd("/")
738+
.build()
739+
.into();
740+
741+
assert_eq!(entry.command, "git status");
742+
}
743+
701744
#[test]
702745
fn test_serialize_deserialize_version() {
703746
// v0

0 commit comments

Comments
 (0)