feat(backend): migrate production backend to Rust

Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
This commit is contained in:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

@@ -0,0 +1,260 @@
use serde_json::{Value, json};
use spotiflac_extensions::RuntimeLimits;
use spotiflac_extensions::environment::ExtensionEnvironment;
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, Instant};
const KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
struct Request {
method: String,
path: String,
headers: BTreeMap<String, String>,
body: Vec<u8>,
}
struct Server {
base: String,
requests: mpsc::Receiver<Request>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl Server {
fn new(responses: &[(&str, &[u8])]) -> Self {
let responses: BTreeMap<_, _> = responses
.iter()
.map(|(path, body)| (path.to_string(), body.to_vec()))
.collect();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let stop = Arc::new(AtomicBool::new(false));
let stopped = stop.clone();
let (sent, requests) = mpsc::channel();
let worker = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
while !stopped.load(Ordering::Acquire) && Instant::now() < deadline {
let (mut stream, _) = match listener.accept() {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(1));
continue;
}
Err(error) => panic!("fetch fixture accept: {error}"),
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut bytes = Vec::new();
let header_end = loop {
if let Some(index) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
break index + 4;
}
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).unwrap();
assert!(count > 0 && bytes.len() + count <= 16384);
bytes.extend_from_slice(&chunk[..count]);
};
let mut lines = std::str::from_utf8(&bytes[..header_end])
.unwrap()
.split("\r\n");
let mut first = lines.next().unwrap().split_whitespace();
let method = first.next().unwrap().to_owned();
let path = first.next().unwrap().to_owned();
let headers: BTreeMap<_, _> = lines
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.to_ascii_lowercase(), value.trim().to_owned()))
.collect();
let length = headers
.get("content-length")
.map(|value| value.parse::<usize>().unwrap())
.unwrap_or(0);
assert!(length <= 1 << 20);
while bytes.len() - header_end < length {
let mut chunk = [0; 16384];
let count = stream.read(&mut chunk).unwrap();
assert!(count > 0 && bytes.len() + count <= (1 << 20) + header_end);
bytes.extend_from_slice(&chunk[..count]);
}
let response = responses.get(&path).map(Vec::as_slice).unwrap_or(b"{}");
sent.send(Request {
method,
path,
headers,
body: bytes[header_end..].to_vec(),
})
.unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response.len()
)
.unwrap();
stream.write_all(response).unwrap();
}
});
Self {
base,
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
fn manifest() -> String {
json!({"name":"example.fetch","version":"1","description":"Generic fetch fixture","type":["metadata_provider"],
"permissions":{"network":["127.0.0.1"],"allowHttp":true}}).to_string()
}
#[test]
fn fetch_json_preserves_go_numbers_normalization_and_fresh_objects() {
let server = Server::new(&[
("/numbers", b"[-0,-0.0,9007199254740993,9223372036854775807,18446744073709551615,1.7976931348623157e308,5e-324,-1e-4000]"),
("/overflow", br#"{"x":1e400,"x":0}"#),
("/malformed", b"{\"value\":\"\xff\xe2\x82\"}"),
("/surrogates", br#"{"\ud800":1,"\ufffd":2,"\ud800":3,"value":"\udfff","pair":"\ud83d\ude00"}"#),
("/object", br#"{"__proto__":{"polluted":true},"nested":{"n":1},"items":[1,2]}"#),
("/invalid", br#"{"x":1,}"#),
("/trailing", b"{}{}"),
("/string", br#"{"x":"[1e400] \" { "}"#),
("/null", b"null"),
]);
let directory = tempfile::tempdir().unwrap();
let environment = ExtensionEnvironment::new(directory.path(), KEY, "1").unwrap();
environment.set_allow_private_network(true).unwrap();
let runtime = environment.load(&manifest(), r#"registerExtension({run(base){
const numbers = fetch(base+'/numbers').json();
const malformed = fetch(base+'/malformed').json();
const surrogates = fetch(base+'/surrogates').json();
const response = fetch(base+'/object');
const first = response.json();
first.nested.n = 9; first.items.push(3); first.extra = true;
const second = response.json();
return {
zero: Object.is(numbers[0],-0) && Object.is(numbers[1],-0) && Object.is(numbers[7],-0),
numbers: numbers[2]===9007199254740992 && numbers[3]===9223372036854775808
&& numbers[4]===18446744073709551616 && numbers[5]===Number.MAX_VALUE && numbers[6]===Number.MIN_VALUE,
overflow: fetch(base+'/overflow').json()===undefined,
malformed: malformed.value==='\ufffd\ufffd\ufffd',
surrogates: surrogates['\ufffd']===3 && surrogates.value==='\ufffd' && surrogates.pair==='\ud83d\ude00',
proto: Object.prototype.hasOwnProperty.call(second,'__proto__')
&& second.__proto__.polluted===true && Object.getPrototypeOf(second)===Object.prototype && ({}).polluted===undefined,
fresh: first!==second && first.nested!==second.nested && second.nested.n===1 && second.items.length===2 && second.extra===undefined,
invalid: fetch(base+'/invalid').json()===undefined && fetch(base+'/trailing').json()===undefined,
string: fetch(base+'/string').json().x==='[1e400] " { ',
null: fetch(base+'/null').json()===null
};
}});"#, RuntimeLimits::default()).unwrap();
let result = runtime
.call("run", &json!([server.base]).to_string(), None, 5000)
.unwrap();
let result: Value = serde_json::from_str(&result).unwrap();
assert_eq!(result.as_object().unwrap().len(), 10);
for (name, value) in result.as_object().unwrap() {
assert_eq!(value, true, "{name}: {result}");
}
environment.shutdown();
}
#[test]
fn direct_http_arguments_preserve_body_bytes_getters_headers_and_content_type() {
let server = Server::new(&[]);
let directory = tempfile::tempdir().unwrap();
let environment = ExtensionEnvironment::new(directory.path(), KEY, "1").unwrap();
environment.set_allow_private_network(true).unwrap();
let runtime = environment.load(&manifest(), r#"registerExtension({run(base){
const events = [];
const url = {toString(){events.push('url');return base+'/large';}};
const body = {
get z(){events.push('body.z');return undefined;},
get a(){events.push('body.a');return '\ud800';},
get blob(){events.push('body.blob');return 'x'.repeat(262144);}
};
const headers = {
get 'X-Zero'(){events.push('header.zero');return -0;},
get 'X-List'(){events.push('header.list');return ['one',2];},
get 'X-Null'(){events.push('header.null');return null;}
};
const options = {
get method(){events.push('method');return 'post';},
get body(){events.push('body');return body;},
get headers(){events.push('headers');return headers;}
};
const large = fetch(url,options);
const empty = http.post(base+'/empty',undefined);
const emptyFetch = fetch(base+'/empty-fetch',{method:'POST'});
const override = fetch(base+'/override',{method:'POST',body:'raw\0\ud800',headers:{'Content-Type':'application/custom'}});
return {events,statuses:[large.status,empty.statusCode,emptyFetch.status,override.status]};
}});"#, RuntimeLimits::default()).unwrap();
let result = runtime
.call("run", &json!([server.base]).to_string(), None, 5000)
.unwrap();
let result: Value = serde_json::from_str(&result).unwrap();
assert_eq!(
result["events"],
json!([
"url",
"method",
"method",
"body",
"body.z",
"body.a",
"body.blob",
"headers",
"header.zero",
"header.list",
"header.null"
])
);
assert_eq!(result["statuses"], json!([200, 200, 200, 200]));
let requests: Vec<_> = (0..4)
.map(|_| {
server
.requests
.recv_timeout(Duration::from_secs(2))
.unwrap()
})
.collect();
assert!(requests.iter().all(|request| request.method == "POST"));
let large = &requests[0];
assert_eq!(large.path, "/large");
let expected = format!(
"{{\"a\":\"\u{fffd}\",\"blob\":\"{}\",\"z\":null}}",
"x".repeat(262144)
);
assert_eq!(large.body, expected.as_bytes());
assert_eq!(large.headers["x-zero"], "-0");
assert_eq!(large.headers["x-list"], "[one 2]");
assert_eq!(large.headers["x-null"], "<nil>");
assert_eq!(large.headers["content-type"], "application/json");
assert_eq!(requests[1].path, "/empty");
assert!(requests[1].body.is_empty());
assert_eq!(requests[1].headers["content-type"], "application/json");
assert_eq!(requests[2].path, "/empty-fetch");
assert!(requests[2].body.is_empty());
assert!(!requests[2].headers.contains_key("content-type"));
assert_eq!(requests[3].path, "/override");
assert_eq!(requests[3].body, b"raw\0\xef\xbf\xbd");
assert_eq!(requests[3].headers["content-type"], "application/custom");
environment.shutdown();
}