Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions crates/noa-app/src/input/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,28 @@ pub fn encode_paste(text: &str, bracketed_paste: bool) -> Option<Vec<u8>> {
/// (applescript Amendment 1.5). Sized to the terminal's OSC 52 clipboard cap
/// (8 MiB decoded) so a scripted write can never queue more than an equivalent
/// clipboard paste; anything longer is truncated on a UTF-8 boundary.
pub(crate) const APPLESCRIPT_INPUT_TEXT_CAP: usize = 8 * 1024 * 1024;
///
/// This equals the pty writer's whole-queue budget (`WRITE_BYTE_CAP`), which
/// is reserved for the *framed* bytes in one go — so the bracketed-paste
/// markers must come out of the same cap ([`BRACKET_FRAME_LEN`]) or a
/// payload cut exactly to the cap is rejected outright by the writer.
pub(crate) const APPLESCRIPT_INPUT_TEXT_CAP: usize = noa_pty::WRITE_BYTE_CAP;

/// Bytes added around a bracketed paste: `ESC[200~` + `ESC[201~`.
const BRACKET_FRAME_LEN: usize = b"\x1b[200~".len() + b"\x1b[201~".len();

/// Encode AppleScript `input text` for the pty (applescript R-7/AC-8). It
/// travels the exact same path as a clipboard paste — bracketed when DECSET
/// 2004 is active, raw otherwise — after first capping the payload to
/// [`APPLESCRIPT_INPUT_TEXT_CAP`] on a UTF-8 boundary. Pure and unit-tested so
/// the byte-level contract can be verified without an Apple Event.
pub(crate) fn applescript_input_bytes(text: &str, bracketed_paste: bool) -> Option<Vec<u8>> {
encode_paste(cap_input_text(text), bracketed_paste)
let cap = if bracketed_paste {
APPLESCRIPT_INPUT_TEXT_CAP - BRACKET_FRAME_LEN
} else {
APPLESCRIPT_INPUT_TEXT_CAP
};
encode_paste(cap_input_text(text, cap), bracketed_paste)
}

/// Encode `noa.sendText`'s `paste: false` payload for the pty (noa-server
Expand All @@ -45,17 +58,17 @@ pub(crate) fn applescript_input_bytes(text: &str, bracketed_paste: bool) -> Opti
/// [`APPLESCRIPT_INPUT_TEXT_CAP`] on a UTF-8 boundary, matching the paste
/// path's bound on how much one RPC call can queue to the pty.
pub(crate) fn raw_input_bytes(text: &str) -> Option<Vec<u8>> {
let capped = cap_input_text(text);
let capped = cap_input_text(text, APPLESCRIPT_INPUT_TEXT_CAP);
if capped.is_empty() {
None
} else {
Some(capped.as_bytes().to_vec())
}
}

fn cap_input_text(text: &str) -> &str {
if text.len() > APPLESCRIPT_INPUT_TEXT_CAP {
let mut end = APPLESCRIPT_INPUT_TEXT_CAP;
fn cap_input_text(text: &str, cap: usize) -> &str {
if text.len() > cap {
let mut end = cap;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
Expand Down
17 changes: 17 additions & 0 deletions crates/noa-app/src/input/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,23 @@ fn applescript_input_caps_oversized_payload_on_char_boundary() {
assert_eq!(bytes.len(), cap - 1);
}

// A payload cut to the cap must still fit the pty writer's whole-queue
// budget *with* the bracketed-paste frame, or the writer rejects it outright.
#[test]
fn applescript_input_bracketed_frame_fits_within_pty_budget() {
let cap = super::paste::APPLESCRIPT_INPUT_TEXT_CAP;
let text = "a".repeat(cap + 100);
let bytes = applescript_input_bytes(&text, true).expect("non-empty");
assert_eq!(bytes.len(), noa_pty::WRITE_BYTE_CAP);
assert!(bytes.starts_with(b"\x1b[200~") && bytes.ends_with(b"\x1b[201~"));
assert!(
noa_pty::PtyWriteBudget::default().reserve(&bytes).is_ok(),
"framed paste at the cap must be reservable on an empty queue"
);
let raw = applescript_input_bytes(&text, false).expect("non-empty");
assert_eq!(raw.len(), noa_pty::WRITE_BYTE_CAP);
}

// noa-server sendText paste:false: bytes pass through untouched, unlike the
// paste path which strips embedded bracket markers and can wrap in ESC[200~.
#[test]
Expand Down
83 changes: 78 additions & 5 deletions crates/noa-config/src/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,47 @@ pub fn write_config_updates(path: &Path, updates: &[(String, String)]) -> io::Re
})?;
fs::create_dir_all(parent)?;

let tmp = target.with_extension("tmp");
fs::write(&tmp, updated)?;
fs::rename(&tmp, &target)?;
// Create the temp file with a unique name so two concurrent writers never
// clobber each other's staging file, and 0600 so a config containing
// e.g. `server-token` is never briefly world-readable via the umask
// default. The existing file's mode (if any) is carried over before the
// rename so a user-tightened (0600) or user-loosened (0644) config keeps
// its permissions across a save.
let existing_mode = fs::metadata(&target).ok().map(|m| m.permissions());
let tmp = parent.join(format!(
".{}.{}.tmp",
target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".to_string()),
std::process::id()
));
let write_result = write_private(&tmp, updated.as_bytes()).and_then(|()| {
if let Some(perms) = existing_mode {
fs::set_permissions(&tmp, perms)?;
}
fs::rename(&tmp, &target)
});
if write_result.is_err() {
let _ = fs::remove_file(&tmp);
}
write_result
}

Ok(())
/// Creates `path` (truncating any stale leftover) with owner-only
/// permissions on unix and writes `contents` to it.
fn write_private(path: &Path, contents: &[u8]) -> io::Result<()> {
use std::io::Write;
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path)?;
file.write_all(contents)?;
file.sync_all()
}

#[cfg(test)]
Expand Down Expand Up @@ -298,7 +334,44 @@ theme = 3024 Day\r
"font-size = 16\n"
);
// No leftover temp file after a successful rename.
assert!(!config_path.with_extension("tmp").exists());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1);
fs::remove_dir_all(dir).unwrap();
}

#[cfg(unix)]
#[test]
fn write_config_updates_preserves_existing_mode() {
use std::os::unix::fs::PermissionsExt;

for mode in [0o600u32, 0o640, 0o644] {
let dir = unique_temp_dir(&format!("mode{mode:o}"));
fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config");
fs::write(&config_path, "font-size = 12\n").unwrap();
fs::set_permissions(&config_path, fs::Permissions::from_mode(mode)).unwrap();

write_config_updates(&config_path, &[("font-size".to_string(), "16".to_string())])
.unwrap();

let got = fs::metadata(&config_path).unwrap().permissions().mode() & 0o777;
assert_eq!(got, mode, "mode {mode:o} not preserved");
fs::remove_dir_all(dir).unwrap();
}
}

#[cfg(unix)]
#[test]
fn write_config_updates_creates_new_file_private() {
use std::os::unix::fs::PermissionsExt;

let dir = unique_temp_dir("newmode");
fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config");

write_config_updates(&config_path, &[("font-size".to_string(), "16".to_string())]).unwrap();

let got = fs::metadata(&config_path).unwrap().permissions().mode() & 0o777;
assert_eq!(got, 0o600);
fs::remove_dir_all(dir).unwrap();
}

Expand Down
Loading