From cf6d4f6dc301c514c0d7b9bddc642d005c29ede6 Mon Sep 17 00:00:00 2001 From: thomasjm Date: Fri, 13 Nov 2026 17:00:01 -0800 Subject: [PATCH 1/5] Echo each invalidation path back on stdout once it has been applied --- src/fuse9p.rs | 116 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 47 deletions(-) diff --git a/src/fuse9p.rs b/src/fuse9p.rs index cb07b45..78a88fc 100644 --- a/src/fuse9p.rs +++ b/src/fuse9p.rs @@ -426,6 +426,7 @@ async fn invalidation_loop( // never returns). Run every notify on a blocking thread so the runtime keeps servicing 9p. let notifier = std::sync::Arc::new(notifier); let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut out = tokio::io::stdout(); loop { let line = match lines.next_line().await { Ok(Some(l)) => l, @@ -435,63 +436,84 @@ async fn invalidation_loop( break; } }; - let rel = line.trim().trim_start_matches('/'); - if rel.is_empty() { - continue; - } - let comps: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect(); - let (name, parent_comps) = match comps.split_last() { - Some(x) => x, - None => continue, - }; - - // Parent inode -> drop the dentry for `name` (forces a fresh lookup, picking up creates, - // deletes and renames). - let pf = client.alloc_fid(); - let parent_ino = match client.walk(root_fid, pf, parent_comps).await { - Ok(qids) => { - let _ = client.clunk(pf).await; - let pqp = qids.last().map(|q| q.path).unwrap_or(root_qid_path); - Some(qid_to_ino(pqp, root_qid_path)) + // Every line read gets exactly one line echoed back, including one this skips, so the + // client can match replies to the paths it sent by order alone. + 'line: { + let rel = line.trim().trim_start_matches('/'); + if rel.is_empty() { + break 'line; } - // The walk did not establish pf, so there is nothing to clunk. - Err(e) => { - tracing::warn!(rel, ?e, "mount9p-fuse: invalidation parent walk failed"); - None - } - }; - if let Some(pino) = parent_ino { - let n = notifier.clone(); - let name_owned = name.to_string(); - let r = tokio::task::spawn_blocking(move || { - n.inval_entry(pino, std::ffi::OsStr::new(&name_owned)) - }) - .await; - tracing::info!(rel, pino, name, result = ?r, "mount9p-fuse: inval_entry"); - } + let comps: Vec<&str> = rel.split('/').filter(|s| !s.is_empty()).collect(); + let (name, parent_comps) = match comps.split_last() { + Some(x) => x, + None => break 'line, + }; - // Child inode -> drop cached attrs + data (forces re-read of changed content). - let cf = client.alloc_fid(); - match client.walk(root_fid, cf, &comps).await { - Ok(qids) => { - let _ = client.clunk(cf).await; - if let Some(q) = qids.last() { - let ino = qid_to_ino(q.path, root_qid_path); - let n = notifier.clone(); - let r = tokio::task::spawn_blocking(move || n.inval_inode(ino, 0, 0)).await; - tracing::info!(rel, ino, result = ?r, "mount9p-fuse: inval_inode"); + // Parent inode -> drop the dentry for `name` (forces a fresh lookup, picking up creates, + // deletes and renames). + let pf = client.alloc_fid(); + let parent_ino = match client.walk(root_fid, pf, parent_comps).await { + Ok(qids) => { + let _ = client.clunk(pf).await; + let pqp = qids.last().map(|q| q.path).unwrap_or(root_qid_path); + Some(qid_to_ino(pqp, root_qid_path)) } + // The walk did not establish pf, so there is nothing to clunk. + Err(e) => { + tracing::warn!(rel, ?e, "mount9p-fuse: invalidation parent walk failed"); + None + } + }; + if let Some(pino) = parent_ino { + let n = notifier.clone(); + let name_owned = name.to_string(); + let r = tokio::task::spawn_blocking(move || { + n.inval_entry(pino, std::ffi::OsStr::new(&name_owned)) + }) + .await; + tracing::info!(rel, pino, name, result = ?r, "mount9p-fuse: inval_entry"); } - // The walk did not establish cf, so there is nothing to clunk. This is the common case: - // an invalidation for a deleted or renamed path no longer resolves. - Err(e) => { - tracing::warn!(rel, ?e, "mount9p-fuse: invalidation child walk failed"); + + // Child inode -> drop cached attrs + data (forces re-read of changed content). + let cf = client.alloc_fid(); + match client.walk(root_fid, cf, &comps).await { + Ok(qids) => { + let _ = client.clunk(cf).await; + if let Some(q) = qids.last() { + let ino = qid_to_ino(q.path, root_qid_path); + let n = notifier.clone(); + let r = tokio::task::spawn_blocking(move || n.inval_inode(ino, 0, 0)).await; + tracing::info!(rel, ino, result = ?r, "mount9p-fuse: inval_inode"); + } + } + // The walk did not establish cf, so there is nothing to clunk. This is the common case: + // an invalidation for a deleted or renamed path no longer resolves. + Err(e) => { + tracing::warn!(rel, ?e, "mount9p-fuse: invalidation child walk failed"); + } } } + echo_applied(&mut out, &line).await; } tracing::info!("mount9p-fuse: invalidation stdin closed"); } +/// Echo a line back on stdout once its kernel notifications have been issued. A client that orders +/// work after an invalidation needs this: writing to our stdin only says the path was queued. +async fn echo_applied(out: &mut tokio::io::Stdout, line: &str) { + use tokio::io::AsyncWriteExt; + let mut buf = String::with_capacity(line.len() + 1); + buf.push_str(line); + buf.push('\n'); + if let Err(e) = out.write_all(buf.as_bytes()).await { + tracing::warn!(?e, "mount9p-fuse: couldn't echo an applied invalidation"); + return; + } + if let Err(e) = out.flush().await { + tracing::warn!(?e, "mount9p-fuse: couldn't flush an applied invalidation"); + } +} + impl Filesystem for Fuse9p { fn init( &mut self, From ca0170f4dd4984cc00b4a7c7ea55730216b8f174 Mon Sep 17 00:00:00 2001 From: thomasjm Date: Fri, 13 Nov 2026 17:18:35 -0800 Subject: [PATCH 2/5] Prefix the invalidation reply so a client can recognise it --- src/fuse9p.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/fuse9p.rs b/src/fuse9p.rs index 78a88fc..a92a067 100644 --- a/src/fuse9p.rs +++ b/src/fuse9p.rs @@ -436,8 +436,8 @@ async fn invalidation_loop( break; } }; - // Every line read gets exactly one line echoed back, including one this skips, so the - // client can match replies to the paths it sent by order alone. + // Every line read gets exactly one reply, including one this skips, so a client can match + // replies to the paths it sent by order alone. 'line: { let rel = line.trim().trim_start_matches('/'); if rel.is_empty() { @@ -498,11 +498,14 @@ async fn invalidation_loop( tracing::info!("mount9p-fuse: invalidation stdin closed"); } -/// Echo a line back on stdout once its kernel notifications have been issued. A client that orders -/// work after an invalidation needs this: writing to our stdin only says the path was queued. +/// Report a path as applied, once its kernel notifications have been issued: `ok ` on stdout. +/// A client that orders work after an invalidation needs this, since writing to our stdin only says +/// the path was queued. The prefix is what lets a client tell a reply from anything else that ever +/// reaches stdout, rather than trusting it to carry replies and nothing else. async fn echo_applied(out: &mut tokio::io::Stdout, line: &str) { use tokio::io::AsyncWriteExt; - let mut buf = String::with_capacity(line.len() + 1); + let mut buf = String::with_capacity(line.len() + 4); + buf.push_str("ok "); buf.push_str(line); buf.push('\n'); if let Err(e) = out.write_all(buf.as_bytes()).await { From 2437badabafff7ae0be51ef5ab8f47d01c340ae4 Mon Sep 17 00:00:00 2001 From: thomasjm Date: Fri, 13 Nov 2026 17:35:49 -0800 Subject: [PATCH 3/5] Carry a request id and escape the path the line protocol can't --- src/fuse9p.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/src/fuse9p.rs b/src/fuse9p.rs index a92a067..eda176e 100644 --- a/src/fuse9p.rs +++ b/src/fuse9p.rs @@ -436,10 +436,24 @@ async fn invalidation_loop( break; } }; - // Every line read gets exactly one reply, including one this skips, so a client can match - // replies to the paths it sent by order alone. + // "inval ", or a bare unescaped path from a client that predates ids. + // Every request gets exactly one reply, including one whose path resolves to nothing. + let (reply_id, path) = match line.strip_prefix("inval ") { + Some(rest) => { + let (id, escaped) = rest.split_once(' ').unwrap_or((rest, "")); + match unescape(escaped) { + Ok(p) => (Some(id.to_string()), p), + Err(e) => { + tracing::warn!(id, escaped, e, "mount9p-fuse: undecodable invalidation path"); + reply(&mut out, &format!("err {id} {e}")).await; + continue; + } + } + } + None => (None, line.clone()), + }; 'line: { - let rel = line.trim().trim_start_matches('/'); + let rel = path.trim().trim_start_matches('/'); if rel.is_empty() { break 'line; } @@ -493,27 +507,49 @@ async fn invalidation_loop( } } } - echo_applied(&mut out, &line).await; + match &reply_id { + Some(id) => reply(&mut out, &format!("ok {id}")).await, + None => reply(&mut out, &format!("ok {path}")).await, + } } tracing::info!("mount9p-fuse: invalidation stdin closed"); } -/// Report a path as applied, once its kernel notifications have been issued: `ok ` on stdout. -/// A client that orders work after an invalidation needs this, since writing to our stdin only says -/// the path was queued. The prefix is what lets a client tell a reply from anything else that ever -/// reaches stdout, rather than trusting it to carry replies and nothing else. -async fn echo_applied(out: &mut tokio::io::Stdout, line: &str) { +/// Undo the escaping of the bytes the line protocol can't carry. The client escapes `%`, LF and CR; +/// any `%XX` is accepted so it can escape more without this needing to change. +fn unescape(s: &str) -> Result { + let b = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] != b'%' { + out.push(b[i]); + i += 1; + continue; + } + let hex = b.get(i + 1..i + 3).ok_or("truncated escape")?; + let hex = std::str::from_utf8(hex).map_err(|_| "bad escape")?; + out.push(u8::from_str_radix(hex, 16).map_err(|_| "bad escape")?); + i += 3; + } + String::from_utf8(out).map_err(|_| "escaped path wasn't utf-8") +} + +/// Write one reply line on stdout, once the request it answers has been applied. A client that +/// orders work after an invalidation needs this, since writing to our stdin only says the path was +/// queued. Replies are tagged so a client can tell one from anything else that ever reaches stdout, +/// and match it to the request it answers rather than relying on position. +async fn reply(out: &mut tokio::io::Stdout, line: &str) { use tokio::io::AsyncWriteExt; - let mut buf = String::with_capacity(line.len() + 4); - buf.push_str("ok "); + let mut buf = String::with_capacity(line.len() + 1); buf.push_str(line); buf.push('\n'); if let Err(e) = out.write_all(buf.as_bytes()).await { - tracing::warn!(?e, "mount9p-fuse: couldn't echo an applied invalidation"); + tracing::warn!(?e, "mount9p-fuse: couldn't write an invalidation reply"); return; } if let Err(e) = out.flush().await { - tracing::warn!(?e, "mount9p-fuse: couldn't flush an applied invalidation"); + tracing::warn!(?e, "mount9p-fuse: couldn't flush an invalidation reply"); } } @@ -1315,3 +1351,37 @@ impl Fuse9p { } } } + +#[cfg(test)] +mod tests { + use super::unescape; + + #[test] + fn decodes_the_delimiters_the_line_protocol_cannot_carry() { + assert_eq!(unescape("we%0Aird.typ"), Ok("we\nird.typ".to_string())); + assert_eq!(unescape("we%0Dird.typ"), Ok("we\rird.typ".to_string())); + assert_eq!(unescape("a%25b.typ"), Ok("a%b.typ".to_string())); + assert_eq!(unescape("%0A%25%0D"), Ok("\n%\r".to_string())); + } + + #[test] + fn leaves_everything_else_alone() { + assert_eq!(unescape("inc/header.typ"), Ok("inc/header.typ".to_string())); + assert_eq!(unescape("."), Ok(".".to_string())); + assert_eq!(unescape(""), Ok("".to_string())); + // Spaces need no escaping: the id is split off at the first one + assert_eq!(unescape("a b.typ"), Ok("a b.typ".to_string())); + // Multi-byte characters pass through, and can also be escaped byte by byte + assert_eq!(unescape("héader.typ"), Ok("héader.typ".to_string())); + assert_eq!(unescape("h%C3%A9ader.typ"), Ok("héader.typ".to_string())); + } + + #[test] + fn rejects_an_escape_it_cannot_decode() { + assert_eq!(unescape("bad%ZZ.typ"), Err("bad escape")); + assert_eq!(unescape("truncated%0"), Err("truncated escape")); + assert_eq!(unescape("truncated%"), Err("truncated escape")); + // A byte sequence that isn't UTF-8 once decoded + assert_eq!(unescape("%FF%FE"), Err("escaped path wasn't utf-8")); + } +} From b1e658a785f94d40f9a5bca8f3416452aed287e0 Mon Sep 17 00:00:00 2001 From: thomasjm Date: Fri, 13 Nov 2026 17:57:20 -0800 Subject: [PATCH 4/5] Percent-decode the invalidation path with percent-encoding --- Cargo.lock | 7 ++++++ Cargo.toml | 3 +++ src/fuse9p.rs | 66 ++++++++++++++++++--------------------------------- 3 files changed, 33 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 423b9ec..390a996 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -438,6 +438,7 @@ dependencies = [ "futures-util", "libc", "nix", + "percent-encoding", "tempfile", "tokio", "tokio-tungstenite", @@ -479,6 +480,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index d164cd2..d0f58de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # the WRITEBACK_CACHE cap (7.23); the default min ABI (7.8) compiles those dispatches out. fuser = { version = "0.14", default-features = false, features = ["abi-7-31"] } libc = "0.2" +# RFC 3986 percent-encoding for the invalidation path: a filename may contain the newline that +# delimits one request from the next. Zero further dependencies. +percent-encoding = "2" [dev-dependencies] tempfile = "3" diff --git a/src/fuse9p.rs b/src/fuse9p.rs index eda176e..278ac1f 100644 --- a/src/fuse9p.rs +++ b/src/fuse9p.rs @@ -441,7 +441,7 @@ async fn invalidation_loop( let (reply_id, path) = match line.strip_prefix("inval ") { Some(rest) => { let (id, escaped) = rest.split_once(' ').unwrap_or((rest, "")); - match unescape(escaped) { + match decode_path(escaped) { Ok(p) => (Some(id.to_string()), p), Err(e) => { tracing::warn!(id, escaped, e, "mount9p-fuse: undecodable invalidation path"); @@ -515,24 +515,13 @@ async fn invalidation_loop( tracing::info!("mount9p-fuse: invalidation stdin closed"); } -/// Undo the escaping of the bytes the line protocol can't carry. The client escapes `%`, LF and CR; -/// any `%XX` is accepted so it can escape more without this needing to change. -fn unescape(s: &str) -> Result { - let b = s.as_bytes(); - let mut out: Vec = Vec::with_capacity(b.len()); - let mut i = 0; - while i < b.len() { - if b[i] != b'%' { - out.push(b[i]); - i += 1; - continue; - } - let hex = b.get(i + 1..i + 3).ok_or("truncated escape")?; - let hex = std::str::from_utf8(hex).map_err(|_| "bad escape")?; - out.push(u8::from_str_radix(hex, 16).map_err(|_| "bad escape")?); - i += 3; - } - String::from_utf8(out).map_err(|_| "escaped path wasn't utf-8") +/// Percent-decode a request's path. The client percent-encodes it because a filename may contain +/// the newline that ends one request, and may contain the `%` that starts an escape. +fn decode_path(s: &str) -> Result { + percent_encoding::percent_decode_str(s) + .decode_utf8() + .map(|p| p.into_owned()) + .map_err(|_| "path wasn't utf-8 once decoded") } /// Write one reply line on stdout, once the request it answers has been applied. A client that @@ -1354,34 +1343,25 @@ impl Fuse9p { #[cfg(test)] mod tests { - use super::unescape; - - #[test] - fn decodes_the_delimiters_the_line_protocol_cannot_carry() { - assert_eq!(unescape("we%0Aird.typ"), Ok("we\nird.typ".to_string())); - assert_eq!(unescape("we%0Dird.typ"), Ok("we\rird.typ".to_string())); - assert_eq!(unescape("a%25b.typ"), Ok("a%b.typ".to_string())); - assert_eq!(unescape("%0A%25%0D"), Ok("\n%\r".to_string())); - } + use super::decode_path; + // The forms the client is expected to send. These pin the agreement between its encoder and + // this decoder, which are separate libraries in separate languages. #[test] - fn leaves_everything_else_alone() { - assert_eq!(unescape("inc/header.typ"), Ok("inc/header.typ".to_string())); - assert_eq!(unescape("."), Ok(".".to_string())); - assert_eq!(unescape(""), Ok("".to_string())); - // Spaces need no escaping: the id is split off at the first one - assert_eq!(unescape("a b.typ"), Ok("a b.typ".to_string())); - // Multi-byte characters pass through, and can also be escaped byte by byte - assert_eq!(unescape("héader.typ"), Ok("héader.typ".to_string())); - assert_eq!(unescape("h%C3%A9ader.typ"), Ok("héader.typ".to_string())); + fn decodes_what_the_client_encodes() { + assert_eq!(decode_path("foo.typ"), Ok("foo.typ".to_string())); + assert_eq!(decode_path("."), Ok(".".to_string())); + assert_eq!(decode_path(""), Ok("".to_string())); + assert_eq!(decode_path("inc%2Fheader.typ"), Ok("inc/header.typ".to_string())); + assert_eq!(decode_path("we%0Aird.typ"), Ok("we\nird.typ".to_string())); + assert_eq!(decode_path("we%0Dird.typ"), Ok("we\rird.typ".to_string())); + assert_eq!(decode_path("a%25b.typ"), Ok("a%b.typ".to_string())); + assert_eq!(decode_path("a%20b.typ"), Ok("a b.typ".to_string())); + assert_eq!(decode_path("h%C3%A9ader.typ"), Ok("héader.typ".to_string())); } #[test] - fn rejects_an_escape_it_cannot_decode() { - assert_eq!(unescape("bad%ZZ.typ"), Err("bad escape")); - assert_eq!(unescape("truncated%0"), Err("truncated escape")); - assert_eq!(unescape("truncated%"), Err("truncated escape")); - // A byte sequence that isn't UTF-8 once decoded - assert_eq!(unescape("%FF%FE"), Err("escaped path wasn't utf-8")); + fn refuses_bytes_that_are_not_utf8_once_decoded() { + assert_eq!(decode_path("%FF%FE"), Err("path wasn't utf-8 once decoded")); } } From 83335c9c0dd630c9f556203e504de7b4b85e9262 Mon Sep 17 00:00:00 2001 From: thomasjm Date: Fri, 13 Nov 2026 18:47:25 -0800 Subject: [PATCH 5/5] Format with the toolchain nix flake check uses --- src/fuse9p.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/fuse9p.rs b/src/fuse9p.rs index 278ac1f..4f4b625 100644 --- a/src/fuse9p.rs +++ b/src/fuse9p.rs @@ -444,7 +444,12 @@ async fn invalidation_loop( match decode_path(escaped) { Ok(p) => (Some(id.to_string()), p), Err(e) => { - tracing::warn!(id, escaped, e, "mount9p-fuse: undecodable invalidation path"); + tracing::warn!( + id, + escaped, + e, + "mount9p-fuse: undecodable invalidation path" + ); reply(&mut out, &format!("err {id} {e}")).await; continue; } @@ -1352,7 +1357,10 @@ mod tests { assert_eq!(decode_path("foo.typ"), Ok("foo.typ".to_string())); assert_eq!(decode_path("."), Ok(".".to_string())); assert_eq!(decode_path(""), Ok("".to_string())); - assert_eq!(decode_path("inc%2Fheader.typ"), Ok("inc/header.typ".to_string())); + assert_eq!( + decode_path("inc%2Fheader.typ"), + Ok("inc/header.typ".to_string()) + ); assert_eq!(decode_path("we%0Aird.typ"), Ok("we\nird.typ".to_string())); assert_eq!(decode_path("we%0Dird.typ"), Ok("we\rird.typ".to_string())); assert_eq!(decode_path("a%25b.typ"), Ok("a%b.typ".to_string()));