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:
2026-07-01 08:36:04 +02:00
parent e7645aae68
commit 0aa8f052fd
4 changed files with 170 additions and 35 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+5
View File
@@ -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 {