use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use crate::proto::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenOptions {
pub window: WindowMode,
pub force_goto: bool,
pub diff: Option<(String, String)>,
pub wait: bool,
pub paths: Vec<String>,
}
pub fn run(opts: OpenOptions) -> Result<()> {
if let Some(url) = opts.paths.iter().find(|p| is_url(p)) {
bail!("`back code` opens files and folders — use `back open {url}` for URLs");
}
if let Some(cli) = vscode_terminal_cli() {
return exec_real_cli(&cli, &to_cli_args(&opts));
}
if channel_is_backchannel() {
return send_plan(opts);
}
if in_ssh_session() {
bail!(
"no backchannel in this ssh session — is the daemon running on your local \
machine, and was this session opened after it started? `back status` has \
details."
);
}
match find_local_code() {
Some(code) => exec_real_cli(&code, &to_cli_args(&opts)),
None => bail!("no VS Code installation found on this machine (looked through PATH)"),
}
}
pub fn run_open(targets: Vec<String>, proxy: bool) -> Result<()> {
let targets = targets
.iter()
.map(|t| normalize_file_url(t))
.collect::<Result<Vec<_>>>()?;
if proxy && !targets.iter().all(|t| is_url(t)) {
bail!("--proxy applies to http(s) URLs only");
}
if channel_is_backchannel() {
return send_open_verb(targets, proxy);
}
if in_ssh_session() {
bail!(
"no backchannel in this ssh session — is the daemon running on your local \
machine, and was this session opened after it started? `back status` has details."
);
}
if proxy {
bail!("--proxy only makes sense from an ssh session (locally the server is already reachable)");
}
for t in &targets {
crate::launch::open_with_default(t)?;
eprintln!("opening {t}");
}
Ok(())
}
fn send_open_verb(targets: Vec<String>, proxy: bool) -> Result<()> {
let sock = std::env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK is not set")?;
let mut stream = UnixStream::connect(&sock).with_context(|| {
format!("connecting to {sock} — is the backchannel daemon running on your local machine?")
})?;
stream.set_read_timeout(Some(Duration::from_secs(60)))?;
stream.set_write_timeout(Some(Duration::from_secs(60)))?;
let hostname = hostname();
let user = std::env::var("USER").unwrap_or_default();
let ssh_connection = std::env::var("SSH_CONNECTION").unwrap_or_default();
for t in &targets {
if is_url(t) {
let req = OpenRequest {
action: Action::Url {
url: t.clone(),
proxy,
},
window: WindowMode::Default,
wait: false,
hostname: hostname.clone(),
user: user.clone(),
ssh_connection: ssh_connection.clone(),
};
write_frame(&mut stream, &extension(EXT_OPEN, &req.encode()))?;
match read_reply(&mut stream)? {
Reply::Success(Some((final_url, target))) if proxy => {
println!("opening {final_url} in your local browser (tunneled to {target})")
}
Reply::Success(_) => println!("opening {t} in your local browser"),
Reply::ExtensionFailure(reason) => bail!("daemon error: {reason}"),
Reply::Failure => bail!(
"the agent behind SSH_AUTH_SOCK is not the backchannel daemon (see README)"
),
}
continue;
}
let path = absolutize(Path::new(t));
let meta = std::fs::metadata(&path)
.with_context(|| format!("reading {}", path.display()))?;
if meta.is_dir() {
bail!(
"{} is a directory — `back open` transfers single files; use `back code {t}` \
to browse it in VS Code",
path.display()
);
}
let basename = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.with_context(|| format!("no filename in {}", path.display()))?;
if meta.len() >= pull_threshold() {
let path_str = path.to_string_lossy().into_owned();
match send_pull(&mut stream, "open", &path_str, meta.len(), &basename)? {
PullOutcome::Done(stats) => {
match stats.summary() {
Some(summary) => println!(
"opening {basename} ({summary}) with the default app on your local machine"
),
None => println!(
"opening {basename} ({} bytes) with the default app on your local machine",
meta.len()
),
}
continue;
}
PullOutcome::Fallback(reason) => {
eprintln!("note: fast pull unavailable ({reason}); transferring inline");
}
}
}
let data = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
if data.len() > crate::clipboard::MAX_COPY_BYTES {
bail!(
"{} is {} bytes, over the {} MiB inline transfer limit (and the daemon could \
not pull it directly)",
path.display(),
data.len(),
crate::clipboard::MAX_COPY_BYTES / (1024 * 1024)
);
}
let len = data.len();
let req = OpenFileRequest {
basename: basename.clone(),
hostname: hostname.clone(),
data,
};
let stats = crate::progress::write_frame_with_progress(
&mut stream,
&extension(EXT_OPENFILE, &req.encode()),
&basename,
)?;
match read_reply(&mut stream)? {
Reply::Success(_) => match stats.summary() {
Some(summary) => println!(
"opening {basename} ({summary}) with the default app on your local machine"
),
None => println!(
"opening {basename} ({len} bytes) with the default app on your local machine"
),
},
Reply::ExtensionFailure(reason) => bail!("daemon error: {reason}"),
Reply::Failure => {
bail!("the agent behind SSH_AUTH_SOCK is not the backchannel daemon (see README)")
}
}
}
Ok(())
}
fn is_url(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub(crate) fn pull_threshold() -> u64 {
std::env::var("BACKCHANNEL_PULL_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4 * 1024 * 1024)
}
pub(crate) enum PullOutcome {
Done(crate::progress::TransferStats),
Fallback(String),
}
pub(crate) fn send_pull(
stream: &mut UnixStream,
disposition: &str,
path: &str,
size: u64,
label: &str,
) -> Result<PullOutcome> {
let req = PullRequest {
disposition: disposition.into(),
path: path.into(),
size,
hostname: hostname(),
user: std::env::var("USER").unwrap_or_default(),
ssh_connection: std::env::var("SSH_CONNECTION").unwrap_or_default(),
};
write_frame(stream, &extension(EXT_PULL, &req.encode()))?;
let start = std::time::Instant::now();
let mut progress = crate::progress::Progress::new(label, size);
loop {
let frame = read_frame(stream).context("waiting for pull progress")?;
if let Some((done, _)) = parse_progress_frame(&frame) {
progress.update(done);
continue;
}
progress.finish();
return match frame.first() {
Some(&SSH_AGENT_SUCCESS) => Ok(PullOutcome::Done(crate::progress::TransferStats {
bytes: size,
elapsed: start.elapsed(),
})),
Some(&SSH_AGENT_EXTENSION_FAILURE) => {
let reason = Cursor::new(&frame[1..])
.str()
.unwrap_or_else(|_| "unknown error".into());
match reason.strip_prefix("PULL-FALLBACK: ") {
Some(r) => Ok(PullOutcome::Fallback(r.to_string())),
None => bail!("daemon error: {reason}"),
}
}
Some(&SSH_AGENT_FAILURE) => {
bail!("the agent behind SSH_AUTH_SOCK is not the backchannel daemon (see README)")
}
_ => bail!("unexpected reply from agent socket"),
};
}
}
fn normalize_file_url(t: &str) -> Result<String> {
if !t.to_ascii_lowercase().starts_with("file://") {
return Ok(t.to_string());
}
let rest = &t["file://".len()..];
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
bail!("file URL {t:?} names a different host — file URLs must refer to this machine");
}
percent_decode(rest)
}
fn percent_decode(s: &str) -> Result<String> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = bytes
.get(i + 1..i + 3)
.and_then(|h| std::str::from_utf8(h).ok())
.with_context(|| format!("truncated percent-escape in {s:?}"))?;
out.push(
u8::from_str_radix(hex, 16)
.with_context(|| format!("bad percent-escape %{hex} in {s:?}"))?,
);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).context("file URL decodes to non-UTF-8")
}
fn to_cli_args(opts: &OpenOptions) -> Vec<String> {
let mut v = Vec::new();
match opts.window {
WindowMode::New => v.push("--new-window".into()),
WindowMode::Reuse => v.push("--reuse-window".into()),
WindowMode::Default => {}
}
if opts.force_goto {
v.push("--goto".into());
}
if opts.wait {
v.push("--wait".into());
}
if let Some((l, r)) = &opts.diff {
v.push("--diff".into());
v.push(l.clone());
v.push(r.clone());
}
v.extend(opts.paths.iter().cloned());
v
}
pub fn run_as_code_shim(args: Vec<String>) -> Result<()> {
if let Some(cli) = vscode_terminal_cli() {
return exec_real_cli(&cli, &args);
}
if channel_is_backchannel() {
let opts = parse_shim_args(args)?;
if let Some(url) = opts.paths.iter().find(|p| is_url(p)) {
bail!("`code` doesn't open URLs — use `back open {url}`");
}
return send_plan(opts);
}
if in_ssh_session() {
bail!(
"no backchannel in this ssh session — is the daemon running on your local \
machine, and was this session opened after it started? `back status` has \
details."
);
}
match find_local_code() {
Some(code) => exec_real_cli(&code, &args),
None => bail!("no VS Code installation found on this machine (looked through PATH)"),
}
}
pub(crate) fn channel_is_backchannel() -> bool {
let Some(sock) = std::env::var_os("SSH_AUTH_SOCK").filter(|s| !s.is_empty()) else {
return false;
};
matches!(crate::daemon::ping(Path::new(&sock)), Ok(Some(_)))
}
pub(crate) fn in_ssh_session() -> bool {
["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
.iter()
.any(|v| std::env::var_os(v).is_some_and(|s| !s.is_empty()))
}
fn find_local_code() -> Option<PathBuf> {
let self_exe = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok());
let is_backchannel_shim = |p: &Path| match p.canonicalize() {
Ok(c) => {
Some(&c) == self_exe.as_ref()
|| c.file_name().is_some_and(|n| n == "back" || n == "backchannel")
}
Err(_) => true, };
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join("code");
if candidate.is_file() && !is_backchannel_shim(&candidate) {
return Some(candidate);
}
}
}
let mut fallbacks = vec![PathBuf::from(
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
)];
if let Some(home) = std::env::var_os("HOME") {
fallbacks.push(
PathBuf::from(home)
.join("Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"),
);
}
fallbacks.into_iter().find(|p| p.is_file())
}
fn parse_shim_args(args: Vec<String>) -> Result<OpenOptions> {
let mut opts = OpenOptions {
window: WindowMode::Default,
force_goto: false,
diff: None,
wait: false,
paths: Vec::new(),
};
let mut diff_flag = false;
for a in args {
match a.as_str() {
"-n" | "--new-window" => opts.window = WindowMode::New,
"-r" | "--reuse-window" => opts.window = WindowMode::Reuse,
"-g" | "--goto" => opts.force_goto = true,
"-d" | "--diff" => diff_flag = true,
"-w" | "--wait" => opts.wait = true,
s if s.starts_with('-') => bail!(
"unsupported flag {s} over backchannel (supported: -n/--new-window, \
-r/--reuse-window, -g/--goto, -d/--diff, -w/--wait)"
),
_ => opts.paths.push(a),
}
}
if diff_flag {
if opts.paths.len() != 2 {
bail!("--diff needs exactly two files");
}
let right = opts.paths.pop().expect("checked len");
let left = opts.paths.pop().expect("checked len");
opts.diff = Some((left, right));
}
if opts.diff.is_none() && opts.paths.is_empty() {
bail!("usage: code [-n|-r] [-g] <path[:line[:col]]>... | code -d <left> <right>");
}
Ok(opts)
}
fn send_plan(opts: OpenOptions) -> Result<()> {
if opts.wait && opts.diff.is_none() && opts.paths.len() != 1 {
bail!("over backchannel, --wait supports exactly one path (or --diff)");
}
let sock = std::env::var("SSH_AUTH_SOCK").context(
"SSH_AUTH_SOCK is not set — backchannel needs an ssh session with agent forwarding \
pointed at the backchannel daemon (see README)",
)?;
let hostname = hostname();
let mut stream = UnixStream::connect(&sock).with_context(|| {
format!("connecting to {sock} — is the backchannel daemon running on your local machine?")
})?;
let read_timeout = if opts.wait {
None
} else {
Some(Duration::from_secs(10))
};
stream.set_read_timeout(read_timeout)?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
let mut requests: Vec<(Action, String)> = Vec::new();
if let Some((l, r)) = &opts.diff {
let left = diff_target(l)?;
let right = diff_target(r)?;
let msg = format!("diffing {left} and {right} in VS Code on your local machine");
requests.push((Action::Diff { left, right }, msg));
} else {
for p in &opts.paths {
let (kind, path, line, col) = classify_target(p, opts.force_goto);
let msg = match (line, col) {
(0, _) => format!("opening {path}"),
(l, 0) => format!("opening {path} at line {l}"),
(l, c) => format!("opening {path} at {l}:{c}"),
};
requests.push((
Action::Open { kind, path, line, col },
format!("{msg} in VS Code on your local machine"),
));
}
}
let user = std::env::var("USER").unwrap_or_default();
let ssh_connection = std::env::var("SSH_CONNECTION").unwrap_or_default();
for (action, msg) in requests {
let req = OpenRequest {
action,
window: opts.window,
wait: opts.wait,
hostname: hostname.clone(),
user: user.clone(),
ssh_connection: ssh_connection.clone(),
};
write_frame(&mut stream, &extension(EXT_OPEN, &req.encode()))?;
match read_reply(&mut stream)? {
Reply::Success(authority) if opts.wait => {
eprintln!(
"{msg}{}; waiting until closed...",
describe_authority(&authority)
);
match read_reply(&mut stream).context("waiting for the editor to close")? {
Reply::Success(_) => eprintln!("editor closed"),
Reply::ExtensionFailure(reason) => bail!("{reason}"),
Reply::Failure => bail!("unexpected agent failure while waiting"),
}
}
Reply::Success(authority) => {
println!("{msg}{}", describe_authority(&authority))
}
Reply::ExtensionFailure(reason) => bail!("daemon error: {reason}"),
Reply::Failure => bail!(
"the agent behind SSH_AUTH_SOCK is not the backchannel daemon — this looks like \
plain agent forwarding. Point ForwardAgent at the backchannel socket in your \
local ssh config (see README)."
),
}
}
Ok(())
}
pub(crate) enum Reply {
Success(Option<(String, String)>),
ExtensionFailure(String),
Failure,
}
fn describe_authority(authority: &Option<(String, String)>) -> String {
match authority {
Some((alias, how)) if how == "ssh argv" => format!(" ({alias})"),
Some((alias, how)) => format!(" ({alias} — resolved via {how})"),
None => String::new(),
}
}
pub(crate) fn read_reply(stream: &mut UnixStream) -> Result<Reply> {
let reply = read_frame(stream).context("waiting for daemon reply")?;
match reply.first() {
Some(&SSH_AGENT_SUCCESS) => {
let mut c = Cursor::new(&reply[1..]);
let authority = match (c.str(), c.str()) {
(Ok(alias), Ok(how)) => Some((alias, how)),
_ => None, };
Ok(Reply::Success(authority))
}
Some(&SSH_AGENT_EXTENSION_FAILURE) => Ok(Reply::ExtensionFailure(
Cursor::new(&reply[1..])
.str()
.unwrap_or_else(|_| "unknown error".into()),
)),
Some(&SSH_AGENT_FAILURE) => Ok(Reply::Failure),
_ => anyhow::bail!("unexpected reply from agent socket"),
}
}
fn diff_target(p: &str) -> Result<String> {
let abs = absolutize(Path::new(p));
match std::fs::metadata(&abs) {
Ok(m) if m.is_dir() => bail!("--diff compares files, and {} is a directory", abs.display()),
Ok(_) => Ok(abs.to_string_lossy().into_owned()),
Err(_) => bail!("diff target {} does not exist", abs.display()),
}
}
fn classify_target(raw: &str, force_goto: bool) -> (Kind, String, u32, u32) {
let literal = absolutize(Path::new(raw));
let literal_meta = std::fs::metadata(&literal).ok();
if !force_goto
&& let Some(m) = &literal_meta {
let kind = if m.is_dir() { Kind::Folder } else { Kind::File };
return (kind, literal.to_string_lossy().into_owned(), 0, 0);
}
if let Some((base, line, col)) = split_goto_suffix(raw) {
let base_abs = absolutize(Path::new(base));
match std::fs::metadata(&base_abs) {
Ok(m) if m.is_dir() => {
return (Kind::Folder, base_abs.to_string_lossy().into_owned(), 0, 0);
}
Ok(_) => return (Kind::File, base_abs.to_string_lossy().into_owned(), line, col),
Err(_) => {
if literal_meta.is_none() {
eprintln!(
"note: {} does not exist; opening as a new file",
base_abs.display()
);
return (Kind::File, base_abs.to_string_lossy().into_owned(), line, col);
}
}
}
}
match literal_meta {
Some(m) if m.is_dir() => (Kind::Folder, literal.to_string_lossy().into_owned(), 0, 0),
Some(_) => (Kind::File, literal.to_string_lossy().into_owned(), 0, 0),
None => {
eprintln!("note: {} does not exist; opening as a file", literal.display());
(Kind::File, literal.to_string_lossy().into_owned(), 0, 0)
}
}
}
fn split_goto_suffix(raw: &str) -> Option<(&str, u32, u32)> {
let (rest, last) = raw.rsplit_once(':')?;
let last_num: u32 = last.parse().ok()?;
if let Some((base, mid)) = rest.rsplit_once(':')
&& let Ok(line) = mid.parse::<u32>()
&& !base.is_empty() {
return Some((base, line, last_num)); }
if rest.is_empty() {
None
} else {
Some((rest, last_num, 0)) }
}
fn absolutize(p: &Path) -> PathBuf {
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(p)
};
let mut out = PathBuf::new();
for c in joined.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
pub(crate) fn hostname() -> String {
let mut buf = [0u8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if rc != 0 {
return "unknown".into();
}
let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).into_owned()
}
fn vscode_terminal_cli() -> Option<PathBuf> {
std::env::var("VSCODE_IPC_HOOK_CLI")
.ok()
.filter(|s| !s.is_empty())?;
let home = PathBuf::from(std::env::var_os("HOME")?);
let mut candidates: Vec<PathBuf> = Vec::new();
for (server_dir, cli_name) in [
(".vscode-server", "code"),
(".vscode-server-insiders", "code-insiders"),
] {
let base = home.join(server_dir);
collect_children(&base.join("cli/servers"), &mut candidates, |d| {
d.join("server/bin/remote-cli").join(cli_name)
});
collect_children(&base.join("bin"), &mut candidates, |d| {
d.join("bin/remote-cli").join(cli_name)
});
}
candidates
.into_iter()
.max_by_key(|p| p.metadata().and_then(|m| m.modified()).ok())
}
fn collect_children(base: &Path, out: &mut Vec<PathBuf>, make: impl Fn(&Path) -> PathBuf) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let candidate = make(&entry.path());
if candidate.is_file() {
out.push(candidate);
}
}
}
fn exec_real_cli(cli: &Path, args: &[String]) -> Result<()> {
use std::os::unix::process::CommandExt;
let err = std::process::Command::new(cli).args(args).exec();
bail!("failed to exec {}: {err}", cli.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absolutize_cleans_dots() {
assert_eq!(
absolutize(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
}
#[test]
fn absolutize_keeps_absolute() {
assert_eq!(absolutize(Path::new("/x/y")), PathBuf::from("/x/y"));
}
#[test]
fn absolutize_joins_cwd() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(absolutize(Path::new("sub/file")), cwd.join("sub/file"));
}
#[test]
fn goto_suffix_line_only() {
assert_eq!(split_goto_suffix("a/b.rs:10"), Some(("a/b.rs", 10, 0)));
}
#[test]
fn goto_suffix_line_and_col() {
assert_eq!(split_goto_suffix("a/b.rs:10:5"), Some(("a/b.rs", 10, 5)));
}
#[test]
fn goto_suffix_absent() {
assert_eq!(split_goto_suffix("a/b.rs"), None);
assert_eq!(split_goto_suffix("a/b.rs:x"), None);
assert_eq!(split_goto_suffix(":10"), None);
}
#[test]
fn goto_suffix_nonnumeric_middle_falls_back_to_line() {
assert_eq!(split_goto_suffix("v1.2:30"), Some(("v1.2", 30, 0)));
}
#[test]
fn shim_args_flags() {
let opts = parse_shim_args(
["-n", "-g", "src/x.rs:3"].iter().map(|s| s.to_string()).collect(),
)
.unwrap();
assert_eq!(opts.window, WindowMode::New);
assert!(opts.force_goto);
assert_eq!(opts.paths, vec!["src/x.rs:3"]);
}
#[test]
fn shim_args_diff() {
let opts =
parse_shim_args(["--diff", "a", "b"].iter().map(|s| s.to_string()).collect()).unwrap();
assert_eq!(opts.diff, Some(("a".into(), "b".into())));
assert!(opts.paths.is_empty());
}
#[test]
fn shim_args_diff_wrong_arity() {
assert!(parse_shim_args(["-d", "a"].iter().map(|s| s.to_string()).collect()).is_err());
}
#[test]
fn shim_args_unknown_flag() {
assert!(
parse_shim_args(["--install-extension", "x"].iter().map(|s| s.to_string()).collect())
.is_err()
);
}
#[test]
fn shim_args_wait() {
let opts =
parse_shim_args(["-w", "notes.md"].iter().map(|s| s.to_string()).collect()).unwrap();
assert!(opts.wait);
assert_eq!(opts.paths, vec!["notes.md"]);
}
#[test]
fn wait_rejects_multiple_paths() {
let opts =
parse_shim_args(["-w", "a", "b"].iter().map(|s| s.to_string()).collect()).unwrap();
let err = send_plan(opts).unwrap_err();
assert!(err.to_string().contains("--wait supports exactly one path"));
}
#[test]
fn classify_existing_file_with_colon_suffix_takes_position() {
let cwd = std::env::current_dir().unwrap();
let (kind, path, line, col) = classify_target("Cargo.toml:7", false);
assert_eq!(kind, Kind::File);
assert_eq!(path, cwd.join("Cargo.toml").to_string_lossy());
assert_eq!((line, col), (7, 0));
}
#[test]
fn file_urls_normalize_to_paths() {
assert_eq!(normalize_file_url("/plain/path").unwrap(), "/plain/path");
assert_eq!(
normalize_file_url("file:///home/x/report.html").unwrap(),
"/home/x/report.html"
);
assert_eq!(
normalize_file_url("file://localhost/tmp/a.svg").unwrap(),
"/tmp/a.svg"
);
assert_eq!(
normalize_file_url("file:///tmp/with%20space.pdf").unwrap(),
"/tmp/with space.pdf"
);
assert!(normalize_file_url("file://otherhost/etc/passwd").is_err());
assert!(normalize_file_url("file:///bad%zz").is_err());
assert_eq!(
normalize_file_url("http://localhost:8000/x").unwrap(),
"http://localhost:8000/x"
);
}
#[test]
fn classify_existing_dir_wins_over_goto() {
let (kind, _, line, _) = classify_target("src", false);
assert_eq!(kind, Kind::Folder);
assert_eq!(line, 0);
}
}