feat(schema): make the fetch guards opt-out via initialization options
Add a FetchPolicy parsed from the client's initializationOptions under a schemaFetch object, defaulting to the strict behavior. Users who need it can opt out per guard: - allowInsecureHttp: also fetch over plaintext http - followRedirects: follow up to five redirects instead of none - maxBytes: raise or remove (0) the response and file size cap The policy is stamped onto each fetch request and honored by the worker.
This commit is contained in:
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
/// How the client and server agree to measure `Position.character`.
|
||||
@@ -32,6 +33,8 @@ pub struct InitializeParams {
|
||||
pub root_uri: Option<String>,
|
||||
#[serde(default, rename = "workspaceFolders")]
|
||||
pub workspace_folders: Option<Vec<WorkspaceFolder>>,
|
||||
#[serde(default, rename = "initializationOptions")]
|
||||
pub initialization_options: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
+149
-29
@@ -15,15 +15,57 @@ use serde_json::Value;
|
||||
|
||||
use crate::schema::resolve;
|
||||
|
||||
/// Largest schema body we will read, for both network and file sources.
|
||||
/// Default largest schema body we will read, for network and file sources.
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// Opt-out relaxations of the network guards, all disabled by default and
|
||||
/// configured through the client's initialization options.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FetchPolicy {
|
||||
pub allow_insecure_http: bool,
|
||||
pub follow_redirects: bool,
|
||||
/// Maximum body size in bytes, or `0` for no limit.
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for FetchPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_insecure_http: false,
|
||||
follow_redirects: false,
|
||||
max_bytes: MAX_BODY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FetchPolicy {
|
||||
/// Read the policy from the client's `initializationOptions`, under a
|
||||
/// `schemaFetch` object. Anything absent keeps the strict default.
|
||||
pub fn from_init_options(options: Option<&Value>) -> Self {
|
||||
let mut policy = Self::default();
|
||||
let Some(cfg) = options.and_then(|o| o.get("schemaFetch")) else {
|
||||
return policy;
|
||||
};
|
||||
if let Some(v) = cfg.get("allowInsecureHttp").and_then(Value::as_bool) {
|
||||
policy.allow_insecure_http = v;
|
||||
}
|
||||
if let Some(v) = cfg.get("followRedirects").and_then(Value::as_bool) {
|
||||
policy.follow_redirects = v;
|
||||
}
|
||||
if let Some(v) = cfg.get("maxBytes").and_then(Value::as_u64) {
|
||||
policy.max_bytes = v;
|
||||
}
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
/// A request to load the schema at `url`. When `allow_network` is false the
|
||||
/// worker only consults the on-disk cache for remote schemas (local files
|
||||
/// are always read), so opening a document never reaches the network.
|
||||
pub struct FetchRequest {
|
||||
pub url: String,
|
||||
pub allow_network: bool,
|
||||
pub policy: FetchPolicy,
|
||||
}
|
||||
|
||||
/// The outcome of a load, sent back to the main thread.
|
||||
@@ -52,8 +94,12 @@ pub fn spawn_worker(
|
||||
let (res_tx, res_rx) = unbounded::<FetchResult>();
|
||||
thread::spawn(move || {
|
||||
for req in req_rx {
|
||||
let result =
|
||||
fetch_one(&req.url, req.allow_network, cache_dir.as_deref());
|
||||
let result = fetch_one(
|
||||
&req.url,
|
||||
req.allow_network,
|
||||
req.policy,
|
||||
cache_dir.as_deref(),
|
||||
);
|
||||
if res_tx.send(result).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -79,6 +125,7 @@ pub fn default_cache_dir() -> Option<PathBuf> {
|
||||
fn fetch_one(
|
||||
url: &str,
|
||||
allow_network: bool,
|
||||
policy: FetchPolicy,
|
||||
cache_dir: Option<&Path>,
|
||||
) -> FetchResult {
|
||||
let cache_file = cache_dir.map(|dir| cache_path(dir, url));
|
||||
@@ -94,13 +141,14 @@ fn fetch_one(
|
||||
}
|
||||
|
||||
// A remote schema that is not cached needs an explicit download.
|
||||
if url.starts_with("https://") && !allow_network {
|
||||
let remote = url.starts_with("https://") || url.starts_with("http://");
|
||||
if remote && !allow_network {
|
||||
return FetchResult::NotCached {
|
||||
url: url.to_owned(),
|
||||
};
|
||||
}
|
||||
|
||||
let body = match load(url) {
|
||||
let body = match load(url, policy) {
|
||||
Ok(body) => body,
|
||||
Err(message) => {
|
||||
return FetchResult::Err {
|
||||
@@ -127,45 +175,65 @@ fn fetch_one(
|
||||
}
|
||||
}
|
||||
|
||||
fn load(url: &str) -> Result<String, String> {
|
||||
if url.starts_with("https://") {
|
||||
fetch_body(&agent(), url)
|
||||
fn load(url: &str, policy: FetchPolicy) -> Result<String, String> {
|
||||
if url.starts_with("https://")
|
||||
|| (policy.allow_insecure_http && url.starts_with("http://"))
|
||||
{
|
||||
fetch_body(&agent(policy), url, policy.max_bytes)
|
||||
} else if url.starts_with("http://") {
|
||||
Err(format!(
|
||||
"refusing plaintext http; enable schemaFetch.allowInsecureHttp \
|
||||
to allow it: {url}"
|
||||
))
|
||||
} else if let Some(path) = resolve::file_url_to_path(url) {
|
||||
read_file(&path)
|
||||
read_file(&path, policy.max_bytes)
|
||||
} else {
|
||||
Err(format!("unsupported schema url: {url}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
fn agent(policy: FetchPolicy) -> ureq::Agent {
|
||||
let redirects = u32::from(policy.follow_redirects) * 5;
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirects(0)
|
||||
.redirects(redirects)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn fetch_body(agent: &ureq::Agent, url: &str) -> Result<String, String> {
|
||||
fn fetch_body(
|
||||
agent: &ureq::Agent,
|
||||
url: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<String, String> {
|
||||
let response = agent.get(url).call().map_err(|e| e.to_string())?;
|
||||
let mut buf = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(MAX_BODY + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if buf.len() as u64 > MAX_BODY {
|
||||
return Err(format!("schema is larger than the {MAX_BODY} byte limit"));
|
||||
let mut reader = response.into_reader();
|
||||
if max_bytes == 0 {
|
||||
reader.read_to_end(&mut buf).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
reader
|
||||
.take(max_bytes + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if buf.len() as u64 > max_bytes {
|
||||
return Err(size_error(max_bytes));
|
||||
}
|
||||
}
|
||||
String::from_utf8(buf).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<String, String> {
|
||||
fn read_file(path: &Path, max_bytes: u64) -> Result<String, String> {
|
||||
let metadata = fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
if metadata.len() > MAX_BODY {
|
||||
return Err(format!("schema is larger than the {MAX_BODY} byte limit"));
|
||||
if max_bytes != 0 && metadata.len() > max_bytes {
|
||||
return Err(size_error(max_bytes));
|
||||
}
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn size_error(max_bytes: u64) -> String {
|
||||
format!("schema is larger than the {max_bytes} byte limit")
|
||||
}
|
||||
|
||||
fn cache_path(dir: &Path, url: &str) -> PathBuf {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
@@ -194,6 +262,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use url::Url;
|
||||
|
||||
use super::FetchPolicy;
|
||||
use super::FetchResult;
|
||||
use super::cache_path;
|
||||
use super::fetch_body;
|
||||
@@ -239,7 +308,7 @@ mod tests {
|
||||
fs::write(cache_path(&dir, url), br#"{"type":"string"}"#).unwrap();
|
||||
|
||||
// A cache hit is returned even when the network is not allowed.
|
||||
match fetch_one(url, false, Some(&dir)) {
|
||||
match fetch_one(url, false, FetchPolicy::default(), Some(&dir)) {
|
||||
FetchResult::Ok { root, .. } => {
|
||||
assert_eq!(root, json!({"type": "string"}));
|
||||
}
|
||||
@@ -250,7 +319,12 @@ mod tests {
|
||||
#[test]
|
||||
fn uncached_remote_without_network_is_not_cached() {
|
||||
let dir = temp_dir("miss");
|
||||
match fetch_one("https://example.com/x.json", false, Some(&dir)) {
|
||||
match fetch_one(
|
||||
"https://example.com/x.json",
|
||||
false,
|
||||
FetchPolicy::default(),
|
||||
Some(&dir),
|
||||
) {
|
||||
FetchResult::NotCached { .. } => {}
|
||||
other => panic!("expected NotCached: {}", describe(&other)),
|
||||
}
|
||||
@@ -263,7 +337,7 @@ mod tests {
|
||||
fs::write(&file, br#"{"type":"number"}"#).unwrap();
|
||||
let url = Url::from_file_path(&file).unwrap().to_string();
|
||||
|
||||
match fetch_one(&url, false, None) {
|
||||
match fetch_one(&url, false, FetchPolicy::default(), None) {
|
||||
FetchResult::Ok { root, .. } => {
|
||||
assert_eq!(root, json!({"type": "number"}));
|
||||
}
|
||||
@@ -280,7 +354,7 @@ mod tests {
|
||||
body
|
||||
));
|
||||
let url = format!("http://127.0.0.1:{port}/schema.json");
|
||||
let text = fetch_body(&no_redirect_agent(), &url).unwrap();
|
||||
let text = fetch_body(&no_redirect_agent(), &url, 0).unwrap();
|
||||
assert_eq!(text, body);
|
||||
}
|
||||
|
||||
@@ -297,14 +371,60 @@ mod tests {
|
||||
body
|
||||
));
|
||||
let url = format!("http://127.0.0.1:{port}/schema.json");
|
||||
assert_eq!(fetch_body(&no_redirect_agent(), &url).unwrap(), body);
|
||||
assert_eq!(fetch_body(&no_redirect_agent(), &url, 0).unwrap(), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_plaintext_http() {
|
||||
match fetch_one("http://example.com/schema.json", true, None) {
|
||||
fn refuses_plaintext_http_by_default() {
|
||||
let r = fetch_one(
|
||||
"http://example.com/schema.json",
|
||||
true,
|
||||
FetchPolicy::default(),
|
||||
None,
|
||||
);
|
||||
match r {
|
||||
FetchResult::Err { .. } => {}
|
||||
other => panic!("http should be rejected: {}", describe(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_http_when_policy_permits() {
|
||||
let body = r#"{"type":"boolean"}"#;
|
||||
let port = serve_once(format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
));
|
||||
let url = format!("http://127.0.0.1:{port}/schema.json");
|
||||
let policy = FetchPolicy {
|
||||
allow_insecure_http: true,
|
||||
..Default::default()
|
||||
};
|
||||
match fetch_one(&url, true, policy, None) {
|
||||
FetchResult::Ok { root, .. } => {
|
||||
assert_eq!(root, json!({"type": "boolean"}));
|
||||
}
|
||||
other => panic!("expected http load: {}", describe(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fetch_policy_from_options() {
|
||||
let options = json!({
|
||||
"schemaFetch": {
|
||||
"allowInsecureHttp": true,
|
||||
"followRedirects": true,
|
||||
"maxBytes": 1024
|
||||
}
|
||||
});
|
||||
let policy = FetchPolicy::from_init_options(Some(&options));
|
||||
assert!(policy.allow_insecure_http);
|
||||
assert!(policy.follow_redirects);
|
||||
assert_eq!(policy.max_bytes, 1024);
|
||||
|
||||
let default = FetchPolicy::from_init_options(None);
|
||||
assert!(!default.allow_insecure_http);
|
||||
assert!(!default.follow_redirects);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-6
@@ -8,6 +8,7 @@ use crossbeam_channel::Sender;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::document::Document;
|
||||
use crate::schema::fetch::FetchPolicy;
|
||||
use crate::schema::fetch::FetchRequest;
|
||||
use crate::schema::fetch::FetchResult;
|
||||
use crate::schema::resolve;
|
||||
@@ -28,6 +29,7 @@ pub struct SchemaStore {
|
||||
waiting: HashMap<String, HashSet<String>>,
|
||||
doc_schema: HashMap<String, String>,
|
||||
workspace_roots: Vec<PathBuf>,
|
||||
policy: FetchPolicy,
|
||||
fetch_tx: Sender<FetchRequest>,
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ impl SchemaStore {
|
||||
waiting: HashMap::new(),
|
||||
doc_schema: HashMap::new(),
|
||||
workspace_roots: Vec::new(),
|
||||
policy: FetchPolicy::default(),
|
||||
fetch_tx,
|
||||
}
|
||||
}
|
||||
@@ -48,6 +51,10 @@ impl SchemaStore {
|
||||
self.workspace_roots = roots;
|
||||
}
|
||||
|
||||
pub fn set_fetch_policy(&mut self, policy: FetchPolicy) {
|
||||
self.policy = policy;
|
||||
}
|
||||
|
||||
/// Record the schema a document declares. Loads it from the on-disk
|
||||
/// cache or a workspace-local file when possible, but never fetches over
|
||||
/// the network automatically. Returns the schema if already in memory.
|
||||
@@ -136,12 +143,12 @@ impl SchemaStore {
|
||||
|
||||
fn enqueue(&mut self, url: String, doc_uri: String, allow_network: bool) {
|
||||
self.waiting.entry(url.clone()).or_default().insert(doc_uri);
|
||||
if self.pending.insert(url.clone())
|
||||
&& self
|
||||
.fetch_tx
|
||||
.send(FetchRequest { url, allow_network })
|
||||
.is_err()
|
||||
{
|
||||
let request = FetchRequest {
|
||||
url: url.clone(),
|
||||
allow_network,
|
||||
policy: self.policy,
|
||||
};
|
||||
if self.pending.insert(url) && self.fetch_tx.send(request).is_err() {
|
||||
eprintln!("jls: schema fetch worker is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::rpc::message::Request;
|
||||
use crate::rpc::message::Response;
|
||||
use crate::rpc::message::ResponseError;
|
||||
use crate::rpc::message::error_codes;
|
||||
use crate::schema::fetch::FetchPolicy;
|
||||
use crate::schema::fetch::FetchRequest;
|
||||
use crate::schema::fetch::FetchResult;
|
||||
use crate::schema::store::SchemaStore;
|
||||
@@ -368,6 +369,10 @@ impl Server {
|
||||
self.encoding = params.negotiate_encoding();
|
||||
self.schema_store
|
||||
.set_workspace_roots(params.workspace_roots());
|
||||
self.schema_store
|
||||
.set_fetch_policy(FetchPolicy::from_init_options(
|
||||
params.initialization_options.as_ref(),
|
||||
));
|
||||
self.initialized = true;
|
||||
|
||||
let result = InitializeResult {
|
||||
|
||||
Reference in New Issue
Block a user