Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d9c9c6de | ||
|
|
51247576f3 | ||
|
|
255f584545 | ||
|
|
d947ccd934 | ||
|
|
cf27ba7ddf |
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.4"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dosh"
|
name = "dosh"
|
||||||
version = "0.1.4"
|
version = "0.1.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build test fmt install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
|
.PHONY: build test fmt clippy release-check install package-release package-release-linux package-release-windows publish-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local tui-harness
|
||||||
|
|
||||||
build:
|
build:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -9,6 +9,16 @@ test:
|
|||||||
fmt:
|
fmt:
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
release-check:
|
||||||
|
cargo fmt --check
|
||||||
|
sh -n install.sh scripts/*.sh
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test
|
||||||
|
$(MAKE) tui-harness
|
||||||
|
|
||||||
install:
|
install:
|
||||||
sh install.sh --from-current
|
sh install.sh --from-current
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ dosh recover HOST # clear cached attach state and re-check
|
|||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dosh palav
|
dosh server
|
||||||
dosh palav tm
|
dosh server tm
|
||||||
dosh forward palav -L 8080:127.0.0.1:80
|
dosh forward server -L 8080:127.0.0.1:80
|
||||||
dosh forward palav -D 1080
|
dosh forward server -D 1080
|
||||||
dosh forward palav -R 2222:127.0.0.1:22
|
dosh forward server -R 2222:127.0.0.1:22
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent forwarding is opt-in with `-A` and must be enabled on the server.
|
Agent forwarding is opt-in with `-A` and must be enabled on the server.
|
||||||
|
|||||||
+14
-4
@@ -410,7 +410,7 @@ write_systemd_service() {
|
|||||||
else
|
else
|
||||||
cat >"$systemd_user_dir/dosh-server.service" <<EOF
|
cat >"$systemd_user_dir/dosh-server.service" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Dosh server
|
Description=dosh dormant shell server
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
@@ -420,6 +420,15 @@ ExecStart=$bindir/dosh-server serve
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
KillMode=process
|
KillMode=process
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=$config_dir $data_dir
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -479,7 +488,8 @@ EOF
|
|||||||
mkdir -p "$systemd_user_dir"
|
mkdir -p "$systemd_user_dir"
|
||||||
write_systemd_service
|
write_systemd_service
|
||||||
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
|
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
|
||||||
systemctl --user enable --now dosh-server.service
|
systemctl --user enable dosh-server.service
|
||||||
|
systemctl --user restart dosh-server.service
|
||||||
fi
|
fi
|
||||||
elif [ "$start_server" -eq 1 ]; then
|
elif [ "$start_server" -eq 1 ]; then
|
||||||
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
|
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
|
||||||
@@ -547,8 +557,8 @@ EOF
|
|||||||
fi
|
fi
|
||||||
cat >"$hosts_config" <<EOF
|
cat >"$hosts_config" <<EOF
|
||||||
# Example:
|
# Example:
|
||||||
# [homelab]
|
# [server]
|
||||||
# ssh = "homelab"
|
# ssh = "server"
|
||||||
# dosh_host = "server.example.com"
|
# dosh_host = "server.example.com"
|
||||||
# port = 50000
|
# port = 50000
|
||||||
# default_command = "tm"
|
# default_command = "tm"
|
||||||
|
|||||||
+270
-21
@@ -1503,7 +1503,54 @@ fn latest_release_download_url(repo: &str, artifact: &str) -> Option<String> {
|
|||||||
Some(format!("{web}/releases/latest/download/{artifact}"))
|
Some(format!("{web}/releases/latest/download/{artifact}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn latest_release_tag_download_url(repo: &str, artifact: &str) -> Result<Option<String>> {
|
fn release_tag_from_effective_url(web: &str, effective: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{web}/releases/tag/");
|
||||||
|
let tag = effective.strip_prefix(&prefix)?;
|
||||||
|
if tag.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tag.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_version_from_tag(tag: &str) -> &str {
|
||||||
|
tag.strip_prefix('v').unwrap_or(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_version_status(local: &str, latest: &str) -> &'static str {
|
||||||
|
match compare_dotted_versions(latest, local) {
|
||||||
|
std::cmp::Ordering::Equal => "current",
|
||||||
|
std::cmp::Ordering::Greater => "update available",
|
||||||
|
std::cmp::Ordering::Less => "different",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering {
|
||||||
|
let left = parse_dotted_version(left);
|
||||||
|
let right = parse_dotted_version(right);
|
||||||
|
let width = left.len().max(right.len());
|
||||||
|
for index in 0..width {
|
||||||
|
let ordering = left
|
||||||
|
.get(index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.cmp(&right.get(index).copied().unwrap_or(0));
|
||||||
|
if ordering != std::cmp::Ordering::Equal {
|
||||||
|
return ordering;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dotted_version(version: &str) -> Vec<u64> {
|
||||||
|
version
|
||||||
|
.split(|byte: char| !byte.is_ascii_digit())
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(|part| part.parse::<u64>().unwrap_or(0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_release_tag(repo: &str) -> Result<Option<String>> {
|
||||||
let web = repo
|
let web = repo
|
||||||
.strip_suffix(".git")
|
.strip_suffix(".git")
|
||||||
.unwrap_or(repo)
|
.unwrap_or(repo)
|
||||||
@@ -1525,14 +1572,18 @@ fn latest_release_tag_download_url(repo: &str, artifact: &str) -> Result<Option<
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let effective = String::from_utf8_lossy(&output.stdout);
|
let effective = String::from_utf8_lossy(&output.stdout);
|
||||||
let prefix = format!("{web}/releases/tag/");
|
Ok(release_tag_from_effective_url(web, &effective))
|
||||||
let Some(tag) = effective.strip_prefix(&prefix) else {
|
}
|
||||||
return Ok(None);
|
|
||||||
};
|
fn release_tag_download_url(repo: &str, tag: &str, artifact: &str) -> Option<String> {
|
||||||
if tag.is_empty() {
|
let web = repo
|
||||||
return Ok(None);
|
.strip_suffix(".git")
|
||||||
|
.unwrap_or(repo)
|
||||||
|
.trim_end_matches('/');
|
||||||
|
if !web.starts_with("http://") && !web.starts_with("https://") {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
Ok(Some(format!("{web}/releases/download/{tag}/{artifact}")))
|
Some(format!("{web}/releases/download/{tag}/{artifact}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn url_reachable(url: &str) -> Result<bool> {
|
fn url_reachable(url: &str) -> Result<bool> {
|
||||||
@@ -1556,13 +1607,27 @@ fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<(
|
|||||||
let installer = format!("{raw_base}/raw/branch/main/install.sh");
|
let installer = format!("{raw_base}/raw/branch/main/install.sh");
|
||||||
let artifact = release_artifact_name();
|
let artifact = release_artifact_name();
|
||||||
if check_only {
|
if check_only {
|
||||||
println!("dosh {}", env!("CARGO_PKG_VERSION"));
|
let local_version = env!("CARGO_PKG_VERSION");
|
||||||
|
let latest_tag = latest_release_tag(&repo)?;
|
||||||
|
println!("dosh {local_version}");
|
||||||
println!("repo: {repo}");
|
println!("repo: {repo}");
|
||||||
println!("installer: {installer}");
|
println!("installer: {installer}");
|
||||||
|
match latest_tag.as_deref() {
|
||||||
|
Some(tag) => {
|
||||||
|
let latest_version = release_version_from_tag(tag);
|
||||||
|
println!(
|
||||||
|
"latest: {latest_version} ({})",
|
||||||
|
update_version_status(local_version, latest_version)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => println!("latest: unknown"),
|
||||||
|
}
|
||||||
if let Some(latest_url) = latest_release_download_url(&repo, artifact) {
|
if let Some(latest_url) = latest_release_download_url(&repo, artifact) {
|
||||||
let mut status = "missing";
|
let mut status = "missing";
|
||||||
let mut display_url = latest_url.clone();
|
let mut display_url = latest_url.clone();
|
||||||
if let Some(tag_url) = latest_release_tag_download_url(&repo, artifact)?
|
if let Some(tag_url) = latest_tag
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|tag| release_tag_download_url(&repo, tag, artifact))
|
||||||
&& url_reachable(&tag_url)?
|
&& url_reachable(&tag_url)?
|
||||||
{
|
{
|
||||||
status = "available";
|
status = "available";
|
||||||
@@ -2862,7 +2927,7 @@ async fn run_terminal(
|
|||||||
let mut stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>> = HashMap::new();
|
let mut stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>> = HashMap::new();
|
||||||
let mut stream_next_recv_offset: HashMap<u64, u64> = HashMap::new();
|
let mut stream_next_recv_offset: HashMap<u64, u64> = HashMap::new();
|
||||||
let mut stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>> = HashMap::new();
|
let mut stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>> = HashMap::new();
|
||||||
let mut pending_user_input: VecDeque<Vec<u8>> = VecDeque::new();
|
let mut pending_user_input: VecDeque<PendingUserInput> = VecDeque::new();
|
||||||
let mut pending_user_input_bytes = 0usize;
|
let mut pending_user_input_bytes = 0usize;
|
||||||
let mut startup_input_hold_until: Option<Instant> = None;
|
let mut startup_input_hold_until: Option<Instant> = None;
|
||||||
let mut startup_gate_mode = StartupGateMode::HoldControl;
|
let mut startup_gate_mode = StartupGateMode::HoldControl;
|
||||||
@@ -2988,7 +3053,7 @@ async fn run_terminal(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if last_packet_at.elapsed() >= Duration::from_secs(2) {
|
if last_packet_at.elapsed() >= Duration::from_secs(2) {
|
||||||
queue_pending_user_input(
|
queue_stale_pending_user_input(
|
||||||
&mut pending_user_input,
|
&mut pending_user_input,
|
||||||
&mut pending_user_input_bytes,
|
&mut pending_user_input_bytes,
|
||||||
bytes,
|
bytes,
|
||||||
@@ -3878,9 +3943,26 @@ fn refresh_live_addr(addr: &mut SocketAddr, cred: &CachedCredential) -> Result<(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn queue_pending_user_input(
|
fn queue_pending_user_input(
|
||||||
pending: &mut VecDeque<Vec<u8>>,
|
pending: &mut VecDeque<PendingUserInput>,
|
||||||
pending_bytes: &mut usize,
|
pending_bytes: &mut usize,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
queue_pending_user_input_with_filter(pending, pending_bytes, bytes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_stale_pending_user_input(
|
||||||
|
pending: &mut VecDeque<PendingUserInput>,
|
||||||
|
pending_bytes: &mut usize,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<()> {
|
||||||
|
queue_pending_user_input_with_filter(pending, pending_bytes, bytes, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_pending_user_input_with_filter(
|
||||||
|
pending: &mut VecDeque<PendingUserInput>,
|
||||||
|
pending_bytes: &mut usize,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
strip_mouse_reports: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let next = pending_bytes
|
let next = pending_bytes
|
||||||
.checked_add(bytes.len())
|
.checked_add(bytes.len())
|
||||||
@@ -3890,10 +3972,19 @@ fn queue_pending_user_input(
|
|||||||
"pending input buffer full while disconnected"
|
"pending input buffer full while disconnected"
|
||||||
);
|
);
|
||||||
*pending_bytes = next;
|
*pending_bytes = next;
|
||||||
pending.push_back(bytes);
|
pending.push_back(PendingUserInput {
|
||||||
|
bytes,
|
||||||
|
strip_mouse_reports,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PendingUserInput {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
strip_mouse_reports: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn split_after_command_submit(bytes: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
fn split_after_command_submit(bytes: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||||
let split_at = bytes
|
let split_at = bytes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -3933,24 +4024,126 @@ async fn flush_pending_user_input(
|
|||||||
cred: &CachedCredential,
|
cred: &CachedCredential,
|
||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
predictor: &mut Predictor,
|
predictor: &mut Predictor,
|
||||||
pending: &mut VecDeque<Vec<u8>>,
|
pending: &mut VecDeque<PendingUserInput>,
|
||||||
pending_bytes: &mut usize,
|
pending_bytes: &mut usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
while let Some(bytes) = pending.pop_front() {
|
while let Some(input) = pending.pop_front() {
|
||||||
|
let PendingUserInput {
|
||||||
|
bytes,
|
||||||
|
strip_mouse_reports,
|
||||||
|
} = input;
|
||||||
*pending_bytes = pending_bytes.saturating_sub(bytes.len());
|
*pending_bytes = pending_bytes.saturating_sub(bytes.len());
|
||||||
|
let bytes = if strip_mouse_reports {
|
||||||
|
strip_stale_mouse_reports(&bytes)
|
||||||
|
} else {
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
if bytes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
predictor.observe_input(&bytes)?;
|
predictor.observe_input(&bytes)?;
|
||||||
send_input(socket, addr, cred, send_seq, bytes).await?;
|
send_input(socket, addr, cred, send_seq, bytes).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn strip_stale_mouse_reports(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < bytes.len() {
|
||||||
|
if let Some(len) = stale_mouse_report_len(bytes, offset) {
|
||||||
|
offset += len;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if stale_mouse_report_maybe_incomplete(bytes, offset) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(bytes[offset]);
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale_mouse_report_len(bytes: &[u8], offset: usize) -> Option<usize> {
|
||||||
|
match bytes.get(offset).copied()? {
|
||||||
|
0x1b if bytes.get(offset + 1) == Some(&b'[') => match bytes.get(offset + 2).copied() {
|
||||||
|
Some(b'M') if offset + 6 <= bytes.len() => Some(6),
|
||||||
|
Some(b'<') => mouse_report_params_len(bytes, offset + 3, 2).map(|len| len + 3),
|
||||||
|
Some(byte) if byte.is_ascii_digit() => {
|
||||||
|
mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
0x9b => match bytes.get(offset + 1).copied() {
|
||||||
|
Some(b'M') if offset + 5 <= bytes.len() => Some(5),
|
||||||
|
Some(b'<') => mouse_report_params_len(bytes, offset + 2, 2).map(|len| len + 2),
|
||||||
|
Some(byte) if byte.is_ascii_digit() => {
|
||||||
|
mouse_report_params_len(bytes, offset + 1, 2).map(|len| len + 1)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
byte if byte.is_ascii_digit() => mouse_report_params_len(bytes, offset, 1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_report_params_len(bytes: &[u8], offset: usize, min_semicolons: usize) -> Option<usize> {
|
||||||
|
let mut semicolons = 0usize;
|
||||||
|
let mut cursor = offset;
|
||||||
|
while let Some(byte) = bytes.get(cursor).copied() {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => cursor += 1,
|
||||||
|
b';' => {
|
||||||
|
semicolons += 1;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
b'M' | b'm' if semicolons >= min_semicolons => return Some(cursor + 1 - offset),
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale_mouse_report_maybe_incomplete(bytes: &[u8], offset: usize) -> bool {
|
||||||
|
let Some(rest) = bytes.get(offset..) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match rest {
|
||||||
|
[0x1b] | [0x1b, b'['] | [0x1b, b'[', b'<'] => true,
|
||||||
|
[0x1b, b'[', b'M', ..] if rest.len() < 6 => true,
|
||||||
|
[0x1b, b'[', b'<', params @ ..] | [0x1b, b'[', params @ ..]
|
||||||
|
if params_are_mouse_prefix(params) =>
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
[0x9b] | [0x9b, b'<'] => true,
|
||||||
|
[0x9b, b'M', ..] if rest.len() < 5 => true,
|
||||||
|
[0x9b, b'<', params @ ..] | [0x9b, params @ ..] if params_are_mouse_prefix(params) => true,
|
||||||
|
params if params_are_mouse_prefix(params) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params_are_mouse_prefix(params: &[u8]) -> bool {
|
||||||
|
let mut semicolons = 0usize;
|
||||||
|
let mut saw_digit = false;
|
||||||
|
for byte in params {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => saw_digit = true,
|
||||||
|
b';' if saw_digit => semicolons += 1,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saw_digit && semicolons >= 1
|
||||||
|
}
|
||||||
|
|
||||||
async fn flush_startup_input_if_ready(
|
async fn flush_startup_input_if_ready(
|
||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
cred: &CachedCredential,
|
cred: &CachedCredential,
|
||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
predictor: &mut Predictor,
|
predictor: &mut Predictor,
|
||||||
pending: (&mut VecDeque<Vec<u8>>, &mut usize),
|
pending: (&mut VecDeque<PendingUserInput>, &mut usize),
|
||||||
hold: (&mut Option<Instant>, &mut StartupGateMode),
|
hold: (&mut Option<Instant>, &mut StartupGateMode),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (startup_hold_until, startup_gate_mode) = hold;
|
let (startup_hold_until, startup_gate_mode) = hold;
|
||||||
@@ -5429,12 +5622,13 @@ mod tests {
|
|||||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key,
|
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_escape_key,
|
||||||
parse_local_forward, parse_remote_forward, parse_ssh_config, post_submit_hold_duration,
|
parse_local_forward, parse_remote_forward, parse_ssh_config, post_submit_hold_duration,
|
||||||
queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr,
|
queue_pending_user_input, raw_contains_host_table, recv_response_until, refresh_live_addr,
|
||||||
|
release_tag_download_url, release_tag_from_effective_url, release_version_from_tag,
|
||||||
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
||||||
retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host,
|
retransmit_stream_opens, rewrite_forward_command, selected_predict_mode, selected_udp_host,
|
||||||
server_version_mismatch, should_hold_during_startup_gate, should_hold_post_submit_input,
|
server_version_mismatch, should_hold_during_startup_gate, should_hold_post_submit_input,
|
||||||
split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user,
|
split_after_command_submit, ssh_destination_host, ssh_username, ssh_with_user,
|
||||||
startup_command, status_ssh_target, toml_bare_key_or_quoted, update_check_requested,
|
startup_command, status_ssh_target, strip_stale_mouse_reports, toml_bare_key_or_quoted,
|
||||||
valid_forward_host,
|
update_check_requested, update_version_status, valid_forward_host,
|
||||||
};
|
};
|
||||||
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
||||||
use dosh::native::EnvVar;
|
use dosh::native::EnvVar;
|
||||||
@@ -6307,6 +6501,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_tag_parses_effective_latest_url() {
|
||||||
|
assert_eq!(
|
||||||
|
release_tag_from_effective_url(
|
||||||
|
"https://example.com/owner/dosh",
|
||||||
|
"https://example.com/owner/dosh/releases/tag/v1.0.0"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("v1.0.0")
|
||||||
|
);
|
||||||
|
assert_eq!(release_version_from_tag("v1.0.0"), "1.0.0");
|
||||||
|
assert_eq!(release_version_from_tag("2026.06.28"), "2026.06.28");
|
||||||
|
assert_eq!(
|
||||||
|
release_tag_download_url(
|
||||||
|
"https://example.com/owner/dosh.git",
|
||||||
|
"v1.0.0",
|
||||||
|
"dosh-linux-x86_64.tar.gz"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some(
|
||||||
|
"https://example.com/owner/dosh/releases/download/v1.0.0/dosh-linux-x86_64.tar.gz"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(release_tag_from_effective_url("https://example.com/owner/dosh", "").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_version_status_compares_numeric_parts() {
|
||||||
|
assert_eq!(update_version_status("0.1.5", "0.1.5"), "current");
|
||||||
|
assert_eq!(update_version_status("0.1.9", "0.1.10"), "update available");
|
||||||
|
assert_eq!(update_version_status("1.0.0", "0.9.9"), "different");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_startup_command_expands_global_extension() {
|
fn resolved_startup_command_expands_global_extension() {
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
@@ -6516,8 +6743,8 @@ mod tests {
|
|||||||
queue_pending_user_input(&mut pending, &mut pending_bytes, b"abc".to_vec()).unwrap();
|
queue_pending_user_input(&mut pending, &mut pending_bytes, b"abc".to_vec()).unwrap();
|
||||||
queue_pending_user_input(&mut pending, &mut pending_bytes, b"def".to_vec()).unwrap();
|
queue_pending_user_input(&mut pending, &mut pending_bytes, b"def".to_vec()).unwrap();
|
||||||
assert_eq!(pending_bytes, 6);
|
assert_eq!(pending_bytes, 6);
|
||||||
assert_eq!(pending.pop_front().unwrap(), b"abc");
|
assert_eq!(pending.pop_front().unwrap().bytes, b"abc");
|
||||||
assert_eq!(pending.pop_front().unwrap(), b"def");
|
assert_eq!(pending.pop_front().unwrap().bytes, b"def");
|
||||||
|
|
||||||
let mut pending = VecDeque::new();
|
let mut pending = VecDeque::new();
|
||||||
let mut pending_bytes = MAX_PENDING_USER_INPUT_BYTES;
|
let mut pending_bytes = MAX_PENDING_USER_INPUT_BYTES;
|
||||||
@@ -6526,6 +6753,28 @@ mod tests {
|
|||||||
assert_eq!(pending_bytes, MAX_PENDING_USER_INPUT_BYTES);
|
assert_eq!(pending_bytes, MAX_PENDING_USER_INPUT_BYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_mouse_reports_are_stripped_from_pending_input() {
|
||||||
|
let input = b"\x1b[<35;152;1Mhello\x1b[<0;107;10m";
|
||||||
|
assert_eq!(strip_stale_mouse_reports(input), b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_mouse_tail_reports_are_stripped_from_pending_input() {
|
||||||
|
let input = b"35;152;1M35;149;1Mls\r";
|
||||||
|
assert_eq!(strip_stale_mouse_reports(input), b"ls\r");
|
||||||
|
assert_eq!(strip_stale_mouse_reports(b"152;1Mls\r"), b"ls\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_mouse_escape_input_survives_stale_filter() {
|
||||||
|
assert_eq!(strip_stale_mouse_reports(b"\x1b[A"), b"\x1b[A");
|
||||||
|
assert_eq!(
|
||||||
|
strip_stale_mouse_reports(b"\x1b[200~paste\x1b[201~"),
|
||||||
|
b"\x1b[200~paste\x1b[201~"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_key_parser_accepts_mosh_style_controls() {
|
fn escape_key_parser_accepts_mosh_style_controls() {
|
||||||
assert_eq!(parse_escape_key("^]").unwrap(), Some(vec![0x1d]));
|
assert_eq!(parse_escape_key("^]").unwrap(), Some(vec![0x1d]));
|
||||||
|
|||||||
Reference in New Issue
Block a user