Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle-cli: Build on Windows
Merged lorenz opened 9 months ago

This patch contains a number of changes, with the main goal of getting radicle-cli to build on Windows.

In order to achieve this, I removed some features:

  1. The spinner in radicle-term does not react to signals on Windows, of course it still reacts to signals on Unix-like platforms.
  2. In radicle file limits are not set on Windows, the implementation always errors. Of course, file limits are still set on Unix-like platforms.

One feature was added: radicle and radicle-ssh now support not only Unix Domain Socket on Unix-like platforms but also Named Pipes on Windows, via the winpipe crate. The (useless?) implementation for connecting to SSH agents via TCP streams was removed.

Some behaviour was changed in radicle-term so that it does not depend on libc and raw file descriptors (which are only available on Unix-like platforms).

26 files changed +462 -417 37ea8176 2a47bc0c
modified Cargo.lock
@@ -651,6 +651,12 @@ dependencies = [
]

[[package]]
+
name = "defer-heavy"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5053691e3e6c0e5979cfb55503b7eb4b06531897b5c15b0f617110096b05a0e1"
+

+
[[package]]
name = "der"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1560,7 +1566,7 @@ dependencies = [
 "iana-time-zone-haiku",
 "js-sys",
 "wasm-bindgen",
-
 "windows-core",
+
 "windows-core 0.52.0",
]

[[package]]
@@ -1946,9 +1952,9 @@ dependencies = [

[[package]]
name = "log"
-
version = "0.4.21"
+
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"

[[package]]
name = "maybe-async"
@@ -2503,6 +2509,7 @@ dependencies = [
 "tempfile",
 "thiserror 1.0.69",
 "unicode-normalization",
+
 "winpipe",
]

[[package]]
@@ -2742,9 +2749,8 @@ dependencies = [
name = "radicle-ssh"
version = "0.9.0"
dependencies = [
-
 "byteorder",
-
 "log",
 "thiserror 1.0.69",
+
 "winpipe",
 "zeroize",
]

@@ -2787,10 +2793,8 @@ dependencies = [
 "crossterm 0.29.0",
 "git2",
 "inquire",
-
 "libc",
 "pretty_assertions",
 "radicle-signals",
-
 "shlex",
 "tempfile",
 "thiserror 1.0.69",
 "unicode-display-width",
@@ -3410,6 +3414,12 @@ dependencies = [
]

[[package]]
+
name = "sync-ptr"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2b115b4cc742d11625f50e0e48ab15baf6fa548c2ec33a8d4113711886316a4f"
+

+
[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3894,6 +3904,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
+
name = "windows"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+
dependencies = [
+
 "windows-core 0.58.0",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3903,6 +3923,60 @@ dependencies = [
]

[[package]]
+
name = "windows-core"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+
dependencies = [
+
 "windows-implement",
+
 "windows-interface",
+
 "windows-result",
+
 "windows-strings",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
+
name = "windows-implement"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
+
name = "windows-interface"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
+
name = "windows-result"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+
dependencies = [
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
+
name = "windows-strings"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+
dependencies = [
+
 "windows-result",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4060,6 +4134,19 @@ dependencies = [
]

[[package]]
+
name = "winpipe"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ccf671d62d1bd0c913d9059e69bb4a6b51f7a4c899ab83c62d921e35f206053"
+
dependencies = [
+
 "defer-heavy",
+
 "log",
+
 "rand",
+
 "sync-ptr",
+
 "windows",
+
]
+

+
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -61,6 +61,7 @@ snapbox = "0.4.3"
sqlite = "0.32.0"
tempfile = "3.3.0"
thiserror = "1.0"
+
winpipe = "0.1.1"
zeroize = "1.5.7"

[workspace.lints]
modified crates/radicle-cli/src/commands/config.rs
@@ -185,12 +185,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                path.display()
            );
        }
-
        Operation::Edit => match term::editor::Editor::new(&path)?.extension("json").edit()? {
-
            Some(_) => {
-
                term::success!("Successfully made changes to the configuration at {path:?}")
+
        Operation::Edit => {
+
            let config = std::fs::read_to_string(&path)?;
+

+
            match term::editor::Editor::new("Edit configuration", term::editor::Editor::HELP)
+
                .editor(term::editor::default_editor_command().as_ref())
+
                .extension(".json")
+
                .initial(&config)
+
                .edit()?
+
            {
+
                Some(edited) => {
+
                    std::fs::write(&path, edited)?;
+
                    term::success!("Successfully made changes to the configuration at {path:?}");
+
                }
+
                None => {
+
                    term::info!("No changes were made to the configuration at {path:?}")
+
                }
            }
-
            None => term::info!("No changes were made to the configuration at {path:?}"),
-
        },
+
        }
    }

    Ok(())
modified crates/radicle-cli/src/commands/id.rs
@@ -412,8 +412,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            // If `--edit` is specified, the document can also be edited via a text edit.
            let proposal = if edit {
                match term::editor::Editor::comment()
-
                    .extension("json")
-
                    .initial(serde_json::to_string_pretty(&current.doc)?)?
+
                    .editor(term::editor::default_editor_command().as_ref())
+
                    .extension(".json")
+
                    .initial(&serde_json::to_string_pretty(&current.doc)?)
                    .edit()?
                {
                    Some(proposal) => serde_json::from_str::<RawDoc>(&proposal)?,
modified crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -836,6 +836,8 @@ enum Error {
    Format(#[from] std::fmt::Error),
    #[error(transparent)]
    Git(#[from] git::raw::Error),
+
    #[error("editor error: {0}")]
+
    Editor(#[from] term::editor::Error),
}

#[derive(Debug)]
@@ -860,8 +862,9 @@ impl CommentBuilder {
            writeln!(&mut input, "> {line}")?;
        }
        let output = term::Editor::comment()
-
            .extension("diff")
-
            .initial(input)?
+
            .editor(term::editor::default_editor_command().as_ref())
+
            .extension(".diff")
+
            .initial(&input)
            .edit()?;

        if let Some(output) = output {
modified crates/radicle-cli/src/commands/self.rs
@@ -146,7 +146,9 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
    let ssh_agent = match ssh::agent::Agent::connect() {
        Ok(c) => term::format::positive(format!(
            "running ({})",
-
            c.pid().map(|p| p.to_string()).unwrap_or(String::from("?"))
+
            c.path()
+
                .map(|p| p.display().to_string())
+
                .unwrap_or(String::from("?"))
        )),
        Err(e) if e.is_not_running() => term::format::yellow(String::from("not running")),
        Err(e) => term::format::negative(format!("error: {e}")),
modified crates/radicle-cli/src/terminal/issue.rs
@@ -1,5 +1,3 @@
-
use std::io;
-

use radicle_term::table::TableOptions;
use radicle_term::{Table, VStack};

@@ -34,7 +32,7 @@ pub enum Format {
pub fn get_title_description(
    title: Option<String>,
    description: Option<String>,
-
) -> io::Result<Option<(String, String)>> {
+
) -> Result<Option<(String, String)>, term::patch::Error> {
    term::patch::Message::edit_title_description(title, description, OPEN_MSG)
}

modified crates/radicle-cli/src/terminal/patch.rs
@@ -31,6 +31,10 @@ pub enum Error {
    Io(#[from] io::Error),
    #[error("invalid utf-8 string")]
    InvalidUtf8,
+
    #[error("editor error: {0}")]
+
    Editor(#[from] term::editor::Error),
+
    #[error("a patch title must be provided")]
+
    PatchTitleMissing,
}

/// The user supplied `Patch` description.
@@ -47,13 +51,14 @@ pub enum Message {

impl Message {
    /// Get the `Message` as a string according to the method.
-
    pub fn get(self, help: &str) -> std::io::Result<String> {
+
    pub fn get(self, help: &str) -> Result<String, term::editor::Error> {
        let comment = match self {
            Message::Edit => {
                if io::stderr().is_terminal() {
                    term::Editor::comment()
-
                        .extension("markdown")
-
                        .initial(help)?
+
                        .editor(term::editor::default_editor_command().as_ref())
+
                        .extension(".md")
+
                        .initial(help)
                        .edit()?
                } else {
                    Some(help.to_owned())
@@ -75,7 +80,7 @@ impl Message {
        title: Option<String>,
        description: Option<String>,
        help: &str,
-
    ) -> std::io::Result<Option<(String, String)>> {
+
    ) -> Result<Option<(String, String)>, Error> {
        let mut placeholder = String::new();

        if let Some(title) = title {
@@ -228,11 +233,7 @@ pub fn get_create_message(
    let (title, description) = (title.trim().to_string(), description.trim().to_string());

    if title.is_empty() {
-
        return Err(io::Error::new(
-
            io::ErrorKind::InvalidInput,
-
            "a patch title must be provided",
-
        )
-
        .into());
+
        return Err(Error::PatchTitleMissing);
    }

    Ok((title, description))
@@ -249,7 +250,7 @@ fn edit_display_message(title: &str, description: &str) -> String {
pub fn get_edit_message(
    patch_message: term::patch::Message,
    patch: &cob::patch::Patch,
-
) -> io::Result<(String, String)> {
+
) -> Result<(String, String), Error> {
    let display_msg = edit_display_message(patch.title(), patch.description());
    let patch_message = patch_message.get(&display_msg)?;
    let patch_message = patch_message.replace(PATCH_MSG.trim(), ""); // Delete help message.
@@ -260,10 +261,7 @@ pub fn get_edit_message(
    let (title, description) = (title.trim().to_string(), description.trim().to_string());

    if title.is_empty() {
-
        return Err(io::Error::new(
-
            io::ErrorKind::InvalidInput,
-
            "a patch title must be provided",
-
        ));
+
        return Err(Error::PatchTitleMissing);
    }

    Ok((title, description))
modified crates/radicle-crypto/src/ssh.rs
@@ -261,13 +261,6 @@ mod test {
    }

    impl ClientStream for DummyStream {
-
        fn connect<P>(_path: P) -> Result<AgentClient<Self>, Error>
-
        where
-
            P: AsRef<std::path::Path> + Send,
-
        {
-
            panic!("This function should never be called!")
-
        }
-

        fn request(&mut self, buf: &[u8]) -> Result<Buffer, Error> {
            *self.incoming.lock().unwrap() = buf.to_vec();

@@ -304,7 +297,7 @@ mod test {
        ];

        let stream = DummyStream::default();
-
        let mut agent = AgentClient::connect(stream.clone());
+
        let mut agent = AgentClient::new(None, stream.clone());

        agent.remove_identity(&pk).unwrap();

@@ -334,7 +327,7 @@ mod test {
        ];

        let stream = DummyStream::default();
-
        let mut agent = AgentClient::connect(stream.clone());
+
        let mut agent = AgentClient::new(None, stream.clone());
        let data: Vec<u8> = vec![1, 2, 3, 4, 5, 6, 7, 8, 9];

        agent.sign(&pk, &data).ok();
modified crates/radicle-crypto/src/ssh/agent.rs
@@ -1,26 +1,23 @@
use std::cell::RefCell;
+
use std::path::Path;

-
pub use radicle_ssh::agent::client::AgentClient;
-
pub use radicle_ssh::agent::client::Error;
-
pub use radicle_ssh::{self as ssh, agent::client::ClientStream};
+
pub use radicle_ssh as ssh;
+
pub use ssh::agent::client::{AgentClient, Error};

use crate::{PublicKey, SecretKey, Signature, Signer, SignerError};

-
#[cfg(not(unix))]
-
pub use std::net::TcpStream as Stream;
-
#[cfg(unix)]
-
pub use std::os::unix::net::UnixStream as Stream;
-

use super::ExtendedSignature;

pub struct Agent {
-
    client: AgentClient<Stream>,
+
    client: AgentClient,
}

impl Agent {
    /// Connect to a running SSH agent.
-
    pub fn connect() -> Result<Self, ssh::agent::client::Error> {
-
        Stream::connect_env().map(|client| Self { client })
+
    pub fn connect() -> Result<Self, Error> {
+
        Ok(Self {
+
            client: AgentClient::connect_env()?,
+
        })
    }

    /// Register a key with the agent.
@@ -45,8 +42,8 @@ impl Agent {
        AgentSigner::new(self, key)
    }

-
    pub fn pid(&self) -> Option<u32> {
-
        self.client.pid()
+
    pub fn path(&self) -> Option<&Path> {
+
        self.client.path()
    }

    pub fn request_identities(&mut self) -> Result<Vec<PublicKey>, ssh::agent::client::Error> {
modified crates/radicle-crypto/src/ssh/keystore.rs
@@ -1,5 +1,4 @@
use std::ops::Deref;
-
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use std::{fs, io};

@@ -94,10 +93,18 @@ impl Keystore {
            return Err(Error::AlreadyInitialized);
        }

-
        fs::DirBuilder::new()
-
            .recursive(true)
-
            .mode(0o700)
-
            .create(&self.path)?;
+
        {
+
            let mut builder = fs::DirBuilder::new();
+
            builder.recursive(true);
+

+
            #[cfg(unix)]
+
            {
+
                use std::os::unix::fs::DirBuilderExt as _;
+
                builder.mode(0o700);
+
            }
+

+
            builder.create(&self.path)?;
+
        }

        secret.write_openssh_file(&path, ssh_key::LineEnding::default())?;
        public.write_openssh_file(&path.with_extension("pub"))?;
modified crates/radicle-remote-helper/src/lib.rs
@@ -90,7 +90,7 @@ pub struct Options {
pub fn run(profile: radicle::Profile) -> Result<(), Error> {
    // Since we're going to be writing user output to `stderr`, make sure the paint
    // module is aware of that.
-
    cli::Paint::set_terminal(io::stderr());
+
    cli::Paint::set_terminal(cli::TerminalFile::Stderr);

    let (remote, url): (Option<git::RefString>, Url) = {
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
modified crates/radicle-ssh/Cargo.toml
@@ -14,7 +14,8 @@ edition.workspace = true
rust-version.workspace = true

[dependencies]
-
byteorder = "1.4"
-
log = { workspace = true }
thiserror = { workspace = true }
zeroize = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies]
+
winpipe = { workspace = true }
modified crates/radicle-ssh/src/agent/client.rs
@@ -1,12 +1,13 @@
use std::fmt;
use std::io::{Read, Write};
-
use std::ops::DerefMut;
-
use std::os::unix::net::UnixStream;
-
use std::path::Path;
-
use std::str::FromStr;
+
use std::path::{Path, PathBuf};
+

+
#[cfg(unix)]
+
pub use std::os::unix::net::UnixStream as Stream;
+

+
#[cfg(windows)]
+
pub use winpipe::WinStream as Stream;

-
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
-
use log::*;
use thiserror::Error;
use zeroize::Zeroize as _;

@@ -21,74 +22,114 @@ pub type Signature = [u8; 64];
#[derive(Debug, Error)]
pub enum Error {
    /// Agent protocol error.
-
    #[error("Agent protocol error")]
+
    #[error("SSH agent replied with unexpected data, violating the SSH agent protocol.")]
    AgentProtocolError,
-
    #[error("Agent failure")]
+
    #[error(
+
        "SSH agent replied with failure (protocol message number 5), which could not be handled."
+
    )]
    AgentFailure,
-
    #[error("Unable to connect to ssh-agent. The environment variable `SSH_AUTH_SOCK` was set, but it points to a nonexistent file or directory.")]
-
    BadAuthSock,
-
    #[error(transparent)]
+
    #[error("Unable to connect to SSH agent because '{path}' was not found: {source}")]
+
    BadAuthSock {
+
        path: String,
+
        source: std::io::Error,
+
    },
+
    #[error("Encoding error while communicating with SSH agent: {0}")]
    Encoding(#[from] encoding::Error),
-
    #[error("Environment variable `{0}` not found")]
-
    EnvVar(&'static str),
-
    #[error(transparent)]
+
    #[error("Unable to read environment variable '{var}': {source}")]
+
    EnvVar {
+
        var: String,
+
        source: std::env::VarError,
+
    },
+
    #[error("Unable to connect SSH agent using the path '{path}': {source}")]
+
    Connect {
+
        path: String,
+
        #[source]
+
        source: std::io::Error,
+
    },
+
    #[error("I/O error while communicating with SSH agent: {0}")]
    Io(#[from] std::io::Error),
-
    #[error(transparent)]
-
    Private(Box<dyn std::error::Error + Send + Sync + 'static>),
-
    #[error(transparent)]
-
    Public(Box<dyn std::error::Error + Send + Sync + 'static>),
-
    #[error(transparent)]
-
    Signature(Box<dyn std::error::Error + Send + Sync + 'static>),
}

impl Error {
    pub fn is_not_running(&self) -> bool {
-
        matches!(self, Self::EnvVar("SSH_AUTH_SOCK"))
+
        matches!(self, Self::EnvVar { .. } | Self::BadAuthSock { .. })
    }
}

/// SSH agent client.
-
pub struct AgentClient<S> {
+
pub struct AgentClient<S = Stream> {
+
    /// The path that was originally used to connect to the agent.
+
    path: Option<PathBuf>,
+

+
    /// The underlying stream to the SSH agent.
    stream: S,
}

impl<S> AgentClient<S> {
-
    /// Connect to an SSH agent via the provided stream (on Unix, usually a Unix-domain socket).
-
    pub fn connect(stream: S) -> Self {
-
        AgentClient { stream }
-
    }
-

-
    /// Get the agent PID.
-
    pub fn pid(&self) -> Option<u32> {
-
        std::env::var("SSH_AGENT_PID")
-
            .ok()
-
            .and_then(|v| u32::from_str(&v).ok())
+
    pub fn path(&self) -> Option<&Path> {
+
        self.path.as_deref()
    }
}

-
pub trait ClientStream: Sized + Send + Sync {
-
    /// Send an agent request through the stream and read the response.
-
    fn request(&mut self, req: &[u8]) -> Result<Buffer, Error>;
-

-
    /// How to connect the streaming socket
-
    fn connect<P>(path: P) -> Result<AgentClient<Self>, Error>
+
impl AgentClient<Stream> {
+
    /// Connect to an SSH agent at the provided path.
+
    pub fn connect<P>(path: P) -> Result<Self, Error>
    where
-
        P: AsRef<Path> + Send;
-

-
    fn connect_env() -> Result<AgentClient<Self>, Error> {
-
        let Ok(var) = std::env::var("SSH_AUTH_SOCK") else {
-
            return Err(Error::EnvVar("SSH_AUTH_SOCK"));
+
        P: AsRef<Path>,
+
    {
+
        let path = path.as_ref().to_owned();
+

+
        let stream = match Stream::connect(&path) {
+
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+
                return Err(Error::BadAuthSock {
+
                    path: path.display().to_string(),
+
                    source: err,
+
                })
+
            }
+
            Err(err) => {
+
                return Err(Error::Connect {
+
                    path: path.display().to_string(),
+
                    source: err,
+
                })
+
            }
+
            Ok(stream) => stream,
        };
-
        match Self::connect(var) {
-
            Err(Error::Io(io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
-
                Err(Error::BadAuthSock)
+

+
        Ok(Self {
+
            path: Some(path),
+
            stream,
+
        })
+
    }
+

+
    pub fn connect_env() -> Result<Self, Error> {
+
        const SSH_AUTH_SOCK: &str = "SSH_AUTH_SOCK";
+

+
        let path = match std::env::var(SSH_AUTH_SOCK) {
+
            Ok(var) => var,
+
            Err(err) => {
+
                if cfg!(windows) {
+
                    // Windows uses a named pipe for the SSH agent, which
+
                    // we fall back to in case reading the environment
+
                    // variable fails.
+
                    "\\\\.\\pipe\\openssh-ssh-agent".to_string()
+
                } else {
+
                    return Err(Error::EnvVar {
+
                        var: SSH_AUTH_SOCK.to_string(),
+
                        source: err,
+
                    });
+
                }
            }
-
            other => other,
-
        }
+
        };
+

+
        Self::connect(path)
    }
}

-
impl<S: ClientStream> AgentClient<S> {
+
impl<Stream: ClientStream> AgentClient<Stream> {
+
    pub fn new(path: Option<PathBuf>, stream: Stream) -> Self {
+
        Self { path, stream }
+
    }
+

    /// Send a key to the agent, with a (possibly empty) slice of constraints
    /// to apply when using the key to sign.
    pub fn add_identity<K>(&mut self, key: &K, constraints: &[Constraint]) -> Result<(), Error>
@@ -112,7 +153,7 @@ impl<S: ClientStream> AgentClient<S> {
                match *cons {
                    Constraint::KeyLifetime { seconds } => {
                        buf.push(msg::CONSTRAIN_LIFETIME);
-
                        buf.deref_mut().write_u32::<BigEndian>(seconds)?
+
                        buf.extend_u32(seconds);
                    }
                    Constraint::Confirm => buf.push(msg::CONSTRAIN_CONFIRM),
                    Constraint::Extensions {
@@ -153,13 +194,12 @@ impl<S: ClientStream> AgentClient<S> {
        buf.extend_ssh_string(pin);

        if !constraints.is_empty() {
-
            buf.deref_mut()
-
                .write_u32::<BigEndian>(constraints.len() as u32)?;
+
            buf.extend_usize(constraints.len());
            for cons in constraints {
                match *cons {
                    Constraint::KeyLifetime { seconds } => {
                        buf.push(msg::CONSTRAIN_LIFETIME);
-
                        buf.deref_mut().write_u32::<BigEndian>(seconds)?;
+
                        buf.extend_u32(seconds);
                    }
                    Constraint::Confirm => buf.push(msg::CONSTRAIN_CONFIRM),
                    Constraint::Extensions {
@@ -271,14 +311,13 @@ impl<S: ClientStream> AgentClient<S> {
        let total = 1 + pk.len() + 4 + data.len() + 4;

        let mut buf = Buffer::default();
-
        buf.write_u32::<BigEndian>(total as u32)
-
            .expect("Writing to a vector never fails");
+
        buf.extend_usize(total);
        buf.push(msg::SIGN_REQUEST);
        buf.extend_from_slice(&pk);
        buf.extend_ssh_string(data);

        // Signature flags should be zero for ed25519.
-
        buf.write_u32::<BigEndian>(0).unwrap();
+
        buf.extend_u32(0);
        buf
    }

@@ -305,7 +344,7 @@ impl<S: ClientStream> AgentClient<S> {
        let total = 1 + pk.len();

        let mut buf = Buffer::default();
-
        buf.write_u32::<BigEndian>(total as u32)?;
+
        buf.extend_usize(total);
        buf.push(msg::REMOVE_IDENTITY);
        buf.extend_from_slice(&pk);

@@ -372,35 +411,11 @@ impl<S: ClientStream> AgentClient<S> {
    }
}

-
#[cfg(not(unix))]
-
impl ClientStream for TcpStream {
-
    fn connect_uds<P>(_: P) -> Result<AgentClient<Self>, Error>
-
    where
-
        P: AsRef<Path> + Send,
-
    {
-
        Err(Error::AgentFailure)
-
    }
-

-
    fn read_response(&mut self, _: &mut Buffer) -> Result<(), Error> {
-
        Err(Error::AgentFailure)
-
    }
-

-
    fn connect_env() -> Result<AgentClient<Self>, Error> {
-
        Err(Error::AgentFailure)
-
    }
+
pub trait ClientStream: Sized + Send + Sync {
+
    fn request(&mut self, msg: &[u8]) -> Result<Buffer, Error>;
}

-
#[cfg(unix)]
-
impl ClientStream for UnixStream {
-
    fn connect<P>(path: P) -> Result<AgentClient<Self>, Error>
-
    where
-
        P: AsRef<Path> + Send,
-
    {
-
        let stream = UnixStream::connect(path)?;
-

-
        Ok(AgentClient { stream })
-
    }
-

+
impl<S: Read + Write + Sized + Send + Sync> ClientStream for S {
    fn request(&mut self, msg: &[u8]) -> Result<Buffer, Error> {
        let mut resp = Buffer::default();

@@ -413,7 +428,8 @@ impl ClientStream for UnixStream {
        self.read_exact(&mut resp)?;

        // Read the rest of the buffer
-
        let len = BigEndian::read_u32(&resp) as usize;
+
        let len = u32::from_be_bytes(resp.as_slice().try_into().unwrap()) as usize;
+

        resp.zeroize();
        resp.resize(len, 0);
        self.read_exact(&mut resp)?;
modified crates/radicle-ssh/src/encoding.rs
@@ -12,9 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
-
use std::ops::DerefMut;
+
use std::ops::DerefMut as _;

-
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
use thiserror::Error;
use zeroize::Zeroizing;

@@ -53,6 +52,16 @@ pub trait Encoding {
    fn write_empty_list(&mut self);
    /// Write the buffer length at the beginning of the buffer.
    fn write_len(&mut self);
+
    /// Push a [`usize`] as an SSH-encoded unsiged 32-bit integer.
+
    /// May panic if the argument is greater than [`u32::MAX`].
+
    /// This is a convience method, to spare callers casting or converting
+
    /// [`usize`] to [`u32`]. If callers end up in a situation where they
+
    /// need to push a 32-bit unisgned integer, but the value they would
+
    /// like to push does not fit 32 bits, then the implementation will not
+
    /// comply with the SSH format anyway.
+
    fn extend_usize(&mut self, u: usize) {
+
        self.extend_u32(u.try_into().unwrap())
+
    }
}

/// Encoding length of the given mpint.
@@ -66,12 +75,12 @@ pub fn mpint_len(s: &[u8]) -> usize {

impl Encoding for Vec<u8> {
    fn extend_ssh_string(&mut self, s: &[u8]) {
-
        self.write_u32::<BigEndian>(s.len() as u32).unwrap();
+
        self.extend_usize(s.len());
        self.extend(s);
    }

    fn extend_ssh_string_blank(&mut self, len: usize) -> &mut [u8] {
-
        self.write_u32::<BigEndian>(len as u32).unwrap();
+
        self.extend_usize(len);
        let current = self.len();
        self.resize(current + len, 0u8);

@@ -86,24 +95,20 @@ impl Encoding for Vec<u8> {
        }
        // If the first non-zero is >= 128, write its length (u32, BE), followed by 0.
        if s[i] & 0x80 != 0 {
-
            self.write_u32::<BigEndian>((s.len() - i + 1) as u32)
-
                .unwrap();
+
            self.extend_usize(s.len() - i + 1);
            self.push(0)
        } else {
-
            self.write_u32::<BigEndian>((s.len() - i) as u32).unwrap();
+
            self.extend_usize(s.len() - i);
        }
        self.extend(&s[i..]);
    }

    fn extend_u32(&mut self, s: u32) {
-
        let mut buf = [0x0; 4];
-
        BigEndian::write_u32(&mut buf, s);
-
        self.extend(buf);
+
        self.extend(s.to_be_bytes());
    }

    fn extend_list<'a, I: Iterator<Item = &'a [u8]>>(&mut self, list: I) {
        let len0 = self.len();
-
        self.extend([0, 0, 0, 0]);

        let mut first = true;
        for i in list {
@@ -116,7 +121,7 @@ impl Encoding for Vec<u8> {
        }
        let len = (self.len() - len0 - 4) as u32;

-
        BigEndian::write_u32(&mut self[len0..], len);
+
        self.splice(len0..len0, len.to_be_bytes());
    }

    fn write_empty_list(&mut self) {
@@ -125,7 +130,7 @@ impl Encoding for Vec<u8> {

    fn write_len(&mut self) {
        let len = self.len() - 4;
-
        BigEndian::write_u32(&mut self[..], len as u32);
+
        self[..4].copy_from_slice((len as u32).to_be_bytes().as_slice());
    }
}

@@ -207,7 +212,8 @@ impl<'a> Cursor<'a> {
    /// Read a `u32` from this reader.
    pub fn read_u32(&mut self) -> Result<u32, Error> {
        if self.position + 4 <= self.s.len() {
-
            let u = BigEndian::read_u32(&self.s[self.position..]);
+
            let u =
+
                u32::from_be_bytes(self.s[self.position..self.position + 4].try_into().unwrap());
            self.position += 4;
            Ok(u)
        } else {
modified crates/radicle-term/Cargo.toml
@@ -15,16 +15,16 @@ default = ["git2"]
[dependencies]
anyhow = { workspace = true }
anstyle-query = "1.0.0"
-
crossbeam-channel = { workspace = true }
crossterm = "0.29.0"
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
-
libc = { workspace = true }
-
shlex = { workspace = true }
thiserror = { workspace = true }
unicode-display-width = "0.3.0"
unicode-segmentation = "1.7.1"
zeroize = { workspace = true }
git2 = { workspace = true, features = ["vendored-libgit2"], optional = true }
+

+
[target.'cfg(unix)'.dependencies]
+
crossbeam-channel = { workspace = true }
radicle-signals = { workspace = true }

[dev-dependencies]
modified crates/radicle-term/src/ansi.rs
@@ -8,10 +8,10 @@ mod paint;
mod style;
#[cfg(test)]
mod tests;
-
mod windows;

pub use color::Color;
pub use paint::paint;
pub use paint::Filled;
pub use paint::Paint;
+
pub use paint::TerminalFile;
pub use style::Style;
modified crates/radicle-term/src/ansi/paint.rs
@@ -1,5 +1,4 @@
use std::io::IsTerminal as _;
-
use std::os::fd::{AsRawFd, BorrowedFd};
use std::sync::atomic::{AtomicBool, AtomicI32};
use std::sync::LazyLock;
use std::{fmt, sync};
@@ -7,8 +6,36 @@ use std::{fmt, sync};
use super::color::Color;
use super::style::{Property, Style};

+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+
#[repr(i32)]
+
pub enum TerminalFile {
+
    Stdout = 1,
+
    Stderr = 2,
+
}
+

+
impl TryFrom<i32> for TerminalFile {
+
    type Error = ();
+

+
    fn try_from(value: i32) -> Result<Self, Self::Error> {
+
        match value {
+
            1 => Ok(TerminalFile::Stdout),
+
            2 => Ok(TerminalFile::Stderr),
+
            _ => Err(()),
+
        }
+
    }
+
}
+

+
impl TerminalFile {
+
    fn is_terminal(&self) -> bool {
+
        match self {
+
            TerminalFile::Stdout => std::io::stdout().is_terminal(),
+
            TerminalFile::Stderr => std::io::stderr().is_terminal(),
+
        }
+
    }
+
}
+

/// What file is used for text output.
-
static TERMINAL: AtomicI32 = AtomicI32::new(libc::STDOUT_FILENO);
+
static TERMINAL: AtomicI32 = AtomicI32::new(TerminalFile::Stdout as i32);
/// Whether paint styling is enabled or not.
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Whether paint styling should be forced.
@@ -263,7 +290,7 @@ impl Paint<()> {
        let clicolor_enabled = clicolor.unwrap_or(false);
        let clicolor_disabled = !clicolor.unwrap_or(true);
        let terminal = TERMINAL.load(sync::atomic::Ordering::SeqCst);
-
        let is_terminal = unsafe { BorrowedFd::borrow_raw(terminal).is_terminal() };
+
        let is_terminal = TerminalFile::try_from(terminal).is_ok_and(|tf| tf.is_terminal());
        let is_enabled = ENABLED.load(sync::atomic::Ordering::SeqCst);

        is_terminal
@@ -287,8 +314,8 @@ impl Paint<()> {

    /// Set the terminal we are writing to. This influences the logic that checks whether or not to
    /// include colors.
-
    pub fn set_terminal(fd: impl AsRawFd) {
-
        TERMINAL.store(fd.as_raw_fd(), sync::atomic::Ordering::SeqCst);
+
    pub fn set_terminal(tf: TerminalFile) {
+
        TERMINAL.store(tf as i32, sync::atomic::Ordering::SeqCst);
    }

    /// Force paint styling.
deleted crates/radicle-term/src/ansi/windows.rs
@@ -1,64 +0,0 @@
-
#[cfg(windows)]
-
mod windows_console {
-
    use std::os::raw::c_void;
-

-
    #[allow(non_camel_case_types)]
-
    type c_ulong = u32;
-
    #[allow(non_camel_case_types)]
-
    type c_int = i32;
-
    type DWORD = c_ulong;
-
    type LPDWORD = *mut DWORD;
-
    type HANDLE = *mut c_void;
-
    type BOOL = c_int;
-

-
    const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004;
-
    const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5;
-
    const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4;
-
    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
-
    const FALSE: BOOL = 0;
-
    const TRUE: BOOL = 1;
-

-
    // This is the win32 console API, taken from the 'winapi' crate.
-
    extern "system" {
-
        fn GetStdHandle(nStdHandle: DWORD) -> HANDLE;
-
        fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL;
-
        fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL;
-
    }
-

-
    unsafe fn get_handle(handle_num: DWORD) -> Result<HANDLE, ()> {
-
        match GetStdHandle(handle_num) {
-
            handle if handle == INVALID_HANDLE_VALUE => Err(()),
-
            handle => Ok(handle),
-
        }
-
    }
-

-
    unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> {
-
        let mut dw_mode: DWORD = 0;
-
        if GetConsoleMode(handle, &mut dw_mode) == FALSE {
-
            return Err(());
-
        }
-

-
        dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
-
        match SetConsoleMode(handle, dw_mode) {
-
            result if result == TRUE => Ok(()),
-
            _ => Err(()),
-
        }
-
    }
-

-
    unsafe fn enable_ansi_colors_raw() -> Result<bool, ()> {
-
        let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?;
-
        let stderr_handle = get_handle(STD_ERROR_HANDLE)?;
-

-
        enable_vt(stdout_handle)?;
-
        if stdout_handle != stderr_handle {
-
            enable_vt(stderr_handle)?;
-
        }
-

-
        Ok(true)
-
    }
-

-
    #[inline]
-
    pub fn enable_ansi_colors() -> bool {
-
        unsafe { enable_ansi_colors_raw().unwrap_or(false) }
-
    }
-
}
modified crates/radicle-term/src/editor.rs
@@ -1,184 +1,69 @@
+
use std::env;
use std::ffi::OsString;
-
use std::io::IsTerminal;
-
use std::io::Write;
-
use std::os::fd::{AsRawFd, FromRawFd};
-
use std::path::{Path, PathBuf};
-
use std::process;
-
use std::{env, fs, io};
+
use std::path::Path;

-
pub const COMMENT_FILE: &str = "RAD_COMMENT";
-
/// Some common paths where system-installed binaries are found.
-
pub const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
+
use inquire::InquireError;
+
use thiserror::Error;

/// Allows for text input in the configured editor.
-
pub struct Editor {
-
    path: PathBuf,
-
    truncate: bool,
-
    cleanup: bool,
-
}
+
pub struct Editor<'a>(pub inquire::Editor<'a>);
+

+
#[derive(Debug, Error)]
+
#[error(transparent)]
+
pub struct Error(#[from] InquireError);

-
impl Default for Editor {
-
    fn default() -> Self {
-
        Self::comment()
+
impl<'a> Editor<'a> {
+
    pub const HELP: &'a str = "(e) to edit. (enter) to save and exit. (esc) to cancel and quit.";
+

+
    /// Create a new editor for editing a comment.
+
    pub fn comment() -> Self {
+
        Self::new("Enter comment.", Self::HELP)
    }
-
}

-
impl Drop for Editor {
-
    fn drop(&mut self) {
-
        if self.cleanup {
-
            fs::remove_file(&self.path).ok();
+
    /// Open the editor and return the edited text.
+
    ///
+
    /// If the text hasn't changed from the initial contents of the editor,
+
    /// return `None`.
+
    pub fn edit(self) -> Result<Option<String>, Error> {
+
        match self.0.prompt() {
+
            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
+
            Err(e) => Err(Error::from(e)),
+
            Ok(s) => Ok(Some(s)),
        }
    }
}

-
impl Editor {
+
impl<'a> Editor<'a> {
    /// Create a new editor.
-
    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
-
        let path = path.as_ref();
-
        if path.try_exists()? {
-
            let meta = fs::metadata(path)?;
-
            if !meta.is_file() {
-
                return Err(io::Error::new(
-
                    io::ErrorKind::InvalidInput,
-
                    "must be used to edit a file",
-
                ));
-
            }
-
        }
-
        Ok(Self {
-
            path: path.to_path_buf(),
-
            truncate: false,
-
            cleanup: false,
-
        })
-
    }
-

-
    pub fn comment() -> Self {
-
        let path = env::temp_dir().join(COMMENT_FILE);
-

-
        Self {
-
            path,
-
            truncate: true,
-
            cleanup: true,
-
        }
+
    pub fn new(message: &'a str, help_message: &'a str) -> Self {
+
        Self(inquire::Editor::new(message).with_help_message(help_message))
    }

    /// Set the file extension.
-
    pub fn extension(mut self, ext: &str) -> Self {
-
        let ext = ext.trim_start_matches('.');
-

-
        self.path.set_extension(ext);
-
        self
-
    }
-

-
    /// Truncate the file to length 0 when opening
-
    pub fn truncate(mut self, truncate: bool) -> Self {
-
        self.truncate = truncate;
-
        self
-
    }
-

-
    /// Clean up the file after the [`Editor`] is dropped.
-
    pub fn cleanup(mut self, cleanup: bool) -> Self {
-
        self.cleanup = cleanup;
-
        self
+
    pub fn extension(self, ext: &'a str) -> Self {
+
        debug_assert!(
+
            ext.starts_with('.'),
+
            "File extension should start with a dot."
+
        );
+
        Self(self.0.with_file_extension(ext))
    }

    /// Initialize the file with the provided `content`, as long as the file
    /// does not already contain anything.
-
    #[allow(clippy::byte_char_slices)]
-
    pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
-
        let content = content.as_ref();
-
        let mut file = fs::OpenOptions::new()
-
            .write(true)
-
            .create(true)
-
            .truncate(self.truncate)
-
            .open(&self.path)?;
-

-
        if file.metadata()?.len() == 0 {
-
            file.write_all(content)?;
-
            if !content.ends_with(&[b'\n']) {
-
                file.write_all(b"\n")?;
-
            }
-
            file.flush()?;
-
        }
-
        Ok(self)
+
    pub fn initial(self, content: &'a str) -> Self {
+
        Self(self.0.with_predefined_text(content))
    }

-
    /// Open the editor and return the edited text.
-
    ///
-
    /// If the text hasn't changed from the initial contents of the editor,
-
    /// return `None`.
-
    pub fn edit(&mut self) -> io::Result<Option<String>> {
-
        let Some(cmd) = self::default_editor() else {
-
            return Err(io::Error::new(
-
                io::ErrorKind::NotFound,
-
                "editor not configured: the `EDITOR` environment variable is not set",
-
            ));
-
        };
-
        let Some(parts) = shlex::split(cmd.to_string_lossy().as_ref()) else {
-
            return Err(io::Error::new(
-
                io::ErrorKind::InvalidInput,
-
                format!("invalid editor command {cmd:?}"),
-
            ));
-
        };
-
        let Some((program, args)) = parts.split_first() else {
-
            return Err(io::Error::new(
-
                io::ErrorKind::InvalidInput,
-
                format!("invalid editor command {cmd:?}"),
-
            ));
-
        };
-

-
        // We duplicate the stderr file descriptor to pass it to the child process, otherwise, if
-
        // we simply pass the `RawFd` of our stderr, `Command` will close our stderr when the
-
        // child exits.
-
        let stderr = io::stderr().as_raw_fd();
-
        let stderr = unsafe { libc::dup(stderr) };
-
        let stdin = if io::stdin().is_terminal() {
-
            process::Stdio::inherit()
-
        } else if cfg!(unix) {
-
            // If standard input is not a terminal device, the editor won't work correctly.
-
            // In that case, we use the terminal device, eg. `/dev/tty` as standard input.
-
            let tty = fs::OpenOptions::new()
-
                .read(true)
-
                .write(true)
-
                .open("/dev/tty")?;
-
            process::Stdio::from(tty)
-
        } else {
-
            return Err(io::Error::new(
-
                io::ErrorKind::Unsupported,
-
                format!("standard input is not a terminal, refusing to execute editor {cmd:?}"),
-
            ));
-
        };
-

-
        process::Command::new(program)
-
            .stdout(unsafe { process::Stdio::from_raw_fd(stderr) })
-
            .stderr(process::Stdio::inherit())
-
            .stdin(stdin)
-
            .args(args)
-
            .arg(&self.path)
-
            .spawn()
-
            .map_err(|e| {
-
                io::Error::new(
-
                    e.kind(),
-
                    format!("failed to spawn editor command {cmd:?}: {e}"),
-
                )
-
            })?
-
            .wait()
-
            .map_err(|e| {
-
                io::Error::new(
-
                    e.kind(),
-
                    format!("editor command {cmd:?} didn't spawn: {e}"),
-
                )
-
            })?;
-

-
        let text = fs::read_to_string(&self.path)?;
-
        if text.trim().is_empty() {
-
            return Ok(None);
+
    pub fn editor(self, editor: Option<&'a OsString>) -> Self {
+
        match editor {
+
            Some(editor_command) => Self(self.0.with_editor_command(editor_command)),
+
            None => self,
        }
-
        Ok(Some(text))
    }
}

/// Get the default editor command.
-
fn default_editor() -> Option<OsString> {
+
pub fn default_editor_command() -> Option<OsString> {
    // First check the standard environment variables.
    if let Ok(visual) = env::var("VISUAL") {
        if !visual.is_empty() {
@@ -211,6 +96,9 @@ fn default_editor() -> Option<OsString> {
/// We don't bother checking the $PATH variable, as we're only looking for very standard tools
/// and prefer not to make this too complex.
fn exists(cmd: &str) -> bool {
+
    // Some common paths where system-installed binaries are found.
+
    const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
+

    for dir in PATHS {
        if Path::new(dir).join(cmd).exists() {
            return true;
modified crates/radicle-term/src/lib.rs
@@ -17,7 +17,7 @@ use std::fmt;
use std::io::IsTerminal;

pub use ansi::Color;
-
pub use ansi::{paint, Filled, Paint, Style};
+
pub use ansi::{paint, Filled, Paint, Style, TerminalFile};
pub use editor::Editor;
pub use element::{Constraint, Element, Line, Size};
pub use hstack::HStack;
modified crates/radicle-term/src/spinner.rs
@@ -3,11 +3,6 @@ use std::mem::ManuallyDrop;
use std::sync::{Arc, Mutex};
use std::{fmt, io, thread, time};

-
use crossbeam_channel as chan;
-

-
use radicle_signals as signals;
-
use signals::Signal;
-

use crate::io::{ERROR_PREFIX, WARNING_PREFIX};
use crate::Paint;

@@ -136,8 +131,13 @@ pub fn spinner_to(
) -> Spinner {
    let message = message.to_string();
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
-
    let (sig_tx, sig_rx) = chan::unbounded();
-
    let sig_result = signals::install(sig_tx);
+

+
    #[cfg(unix)]
+
    let (sig_tx, sig_rx) = crossbeam_channel::unbounded();
+

+
    #[cfg(unix)]
+
    let sig_result = radicle_signals::install(sig_tx);
+

    let handle = thread::Builder::new()
        .name(String::from("spinner"))
        .spawn({
@@ -151,9 +151,13 @@ pub fn spinner_to(
                        break;
                    };
                    // If were unable to install handles, skip signal processing entirely.
+
                    #[cfg(unix)]
                    if sig_result.is_ok() {
                        match sig_rx.try_recv() {
-
                            Ok(sig) if sig == Signal::Interrupt || sig == Signal::Terminate => {
+
                            Ok(sig)
+
                                if sig == radicle_signals::Signal::Interrupt
+
                                    || sig == radicle_signals::Signal::Terminate =>
+
                            {
                                write!(animation, "\r{CLEAR_UNTIL_NEWLINE}").ok();
                                writeln!(
                                    completion,
@@ -225,8 +229,9 @@ pub fn spinner_to(

                write!(animation, "{}", crossterm::cursor::Show).ok();

+
                #[cfg(unix)]
                if sig_result.is_ok() {
-
                    let _ = signals::uninstall();
+
                    let _ = radicle_signals::uninstall();
                }
            }
        })
modified crates/radicle/Cargo.toml
@@ -26,7 +26,6 @@ fast-glob = { version = "0.3.2" }
fastrand = { workspace = true }
git2 = { workspace = true, features = ["vendored-libgit2"] }
indexmap = { version = "2", features = ["serde"] }
-
libc = { workspace = true }
localtime = { workspace = true, features = ["serde"] }
log = { workspace = true, features = ["std"] }
multibase = { workspace = true }
@@ -46,6 +45,12 @@ tempfile = { workspace = true }
thiserror = { workspace = true }
unicode-normalization = { version = "0.1" }

+
[target.'cfg(unix)'.dependencies]
+
libc = { workspace = true }
+

+
[target.'cfg(windows)'.dependencies]
+
winpipe = { workspace = true }
+

[dev-dependencies]
emojis = "0.6"
jsonschema = { version = "0.30", default-features = false }
@@ -53,4 +58,4 @@ pretty_assertions = { workspace = true }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle-cob = { workspace = true, features = ["stable-commit-ids"] }
-
radicle-crypto = { workspace = true, features = ["test"] }

\ No newline at end of file
+
radicle-crypto = { workspace = true, features = ["test"] }
modified crates/radicle/src/io.rs
@@ -1,14 +1,25 @@
-
use std::fmt;
use std::io;

+
#[cfg(unix)]
use libc::{getrlimit, rlim_t, rlimit, setrlimit, RLIMIT_NOFILE};

+
#[cfg(unix)]
type Int = rlim_t;

+
#[cfg(not(unix))]
+
#[inline]
+
pub fn set_file_limit<T>(_: T) -> io::Result<()> {
+
    Err(io::Error::new(
+
        io::ErrorKind::Unsupported,
+
        "setting file limits is not supported on this platform",
+
    ))
+
}
+

/// Sets the open file limit to the given value, or the maximum allowed value.
+
#[cfg(unix)]
pub fn set_file_limit<N>(n: N) -> io::Result<Int>
where
-
    N: Copy + fmt::Display,
+
    N: Copy + std::fmt::Display,
    Int: TryFrom<N>,
{
    let Ok(n) = Int::try_from(n) else {
modified crates/radicle/src/node.rs
@@ -19,11 +19,15 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::io::{BufRead, BufReader};
use std::marker::PhantomData;
use std::ops::{ControlFlow, Deref};
-
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io, net, thread, time};

+
#[cfg(unix)]
+
use std::os::unix::net::UnixStream as Stream;
+
#[cfg(windows)]
+
use winpipe::WinStream as Stream;
+

use amplify::WrapperMut;
use cyphernet::addr::NetAddr;
use localtime::{LocalDuration, LocalTime};
@@ -1140,7 +1144,7 @@ pub trait Handle: Clone + Sync + Send {
/// The iterator blocks for a `timeout` duration, returning [`Error::TimedOut`]
/// if the duration is reached.
pub struct LineIter<T> {
-
    stream: BufReader<UnixStream>,
+
    stream: BufReader<Stream>,
    timeout: time::Duration,
    witness: PhantomData<T>,
}
@@ -1204,9 +1208,9 @@ impl Node {
        cmd: Command,
        timeout: time::Duration,
    ) -> Result<LineIter<T>, Error> {
-
        let stream = UnixStream::connect(&self.socket)
+
        let mut stream = Stream::connect(&self.socket)
            .map_err(|e| Error::Connect(self.socket.clone(), e.kind()))?;
-
        cmd.to_writer(&stream)?;
+
        cmd.to_writer(&mut stream)?;
        Ok(LineIter {
            stream: BufReader::new(stream),
            timeout,
modified crates/radicle/src/profile.rs
@@ -450,15 +450,62 @@ impl AliasStore for Aliases {

/// Get the path to the radicle home folder.
pub fn home() -> Result<Home, io::Error> {
-
    if let Some(home) = env::var_os(env::RAD_HOME) {
-
        Ok(Home::new(PathBuf::from(home))?)
-
    } else if let Some(home) = env::var_os("HOME") {
-
        Ok(Home::new(PathBuf::from(home).join(".radicle"))?)
-
    } else {
-
        Err(io::Error::new(
+
    #[cfg(unix)]
+
    const ERROR_MESSAGE_UNSET: &str =
+
        "Environment variables `RAD_HOME` and `HOME` are both unset or not valid Unicode.";
+

+
    #[cfg(windows)]
+
    const ERROR_MESSAGE_UNSET: &str =
+
        "Environment variables `RAD_HOME`, `HOME`, and `USERPROFILE` are all unset or not valid Unicode.";
+

+
    struct DetectedHome {
+
        path: String,
+

+
        /// Depending on the detection method, we may need to join `.radicle` to the detected path.
+
        join_dot_radicle: bool,
+
    }
+

+
    let detected = {
+
        match env::var(env::RAD_HOME).ok() {
+
            Some(path) => Some(DetectedHome {
+
                path,
+
                join_dot_radicle: false,
+
            }),
+
            None => env::var("HOME")
+
                .ok()
+
                .or_else(|| {
+
                    cfg!(windows)
+
                        .then(|| env::var("USERPROFILE").ok())
+
                        .flatten()
+
                })
+
                .map(|path| DetectedHome {
+
                    path,
+
                    join_dot_radicle: true,
+
                }),
+
        }
+
    };
+

+
    match detected {
+
        Some(DetectedHome {
+
            path,
+
            join_dot_radicle,
+
        }) => {
+
            let home = {
+
                let path = PathBuf::from(path);
+

+
                if join_dot_radicle {
+
                    path.join(".radicle")
+
                } else {
+
                    path
+
                }
+
            };
+

+
            Ok(Home::new(home)?)
+
        }
+
        None => Err(io::Error::new(
            io::ErrorKind::NotFound,
-
            "Neither `RAD_HOME` nor `HOME` are set",
-
        ))
+
            ERROR_MESSAGE_UNSET.to_string(),
+
        )),
    }
}