use crate::error::StoreError;
use crate::models::fe3::{ApplicabilityBlob, PackageInstance};
use crate::utilities::helpers::string_to_package_type;
use log::{debug, trace, warn};
// ---------------------------------------------------------------------------
// Endpoint constants
// ---------------------------------------------------------------------------
const FE3_DELIVERY: &str = "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx";
const FE3_DELIVERY_SECURED: &str =
"https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx/secured";
// ---------------------------------------------------------------------------
// Embedded XML templates
// ---------------------------------------------------------------------------
const GET_COOKIE_XML: &str = include_str!("../xml/get_cookie.xml");
const WUID_REQUEST_XML: &str = include_str!("../xml/wuid_request.xml");
const FE3_FILE_URL_XML: &str = include_str!("../xml/fe3_file_url.xml");
// ---------------------------------------------------------------------------
// Default MSA device token (from original StoreLib C# source)
// ---------------------------------------------------------------------------
const MSA_TOKEN: &str = "<Device>dAA9AEUAdwBBAHcAQQBzAE4AMwBCAEEAQQBVADEAYgB5AHMAZQBtAGIAZQBEAFYAQwArADMAZgBtADcAbwBXAHkASAA3AGIAbgBnAEcAWQBtAEEAQQBMAGoAbQBqAFYAVQB2AFEAYwA0AEsAVwBFAC8AYwBDAEwANQBYAGUANABnAHYAWABkAGkAegBHAGwAZABjADEAZAAvAFcAeQAvAHgASgBQAG4AVwBRAGUAYwBtAHYAbwBjAGkAZwA5AGoAZABwAE4AawBIAG0AYQBzAHAAVABKAEwARAArAFAAYwBBAFgAbQAvAFQAcAA3AEgAagBzAEYANAA0AEgAdABsAC8AMQBtAHUAcgAwAFMAdQBtAG8AMABZAGEAdgBqAFIANwArADQAcABoAC8AcwA4ADEANgBFAFkANQBNAFIAbQBnAFIAQwA2ADMAQwBSAEoAQQBVAHYAZgBzADQAaQB2AHgAYwB5AEwAbAA2AHoAOABlAHgAMABrAFgAOQBPAHcAYQB0ADEAdQBwAFMAOAAxAEgANgA4AEEASABzAEoAegBnAFQAQQBMAG8AbgBBADIAWQBBAEEAQQBpAGcANQBJADMAUQAvAFYASABLAHcANABBAEIAcQA5AFMAcQBhADEAQgA4AGsAVQAxAGEAbwBLAEEAdQA0AHYAbABWAG4AdwBWADMAUQB6AHMATgBtAEQAaQBqAGgANQBkAEcAcgBpADgAQQBlAEUARQBWAEcAbQBXAGgASQBCAE0AUAAyAEQAVwA0ADMAZABWAGkARABUAHoAVQB0AHQARQBMAEgAaABSAGYAcgBhAGIAWgBsAHQAQQBUAEUATABmAHMARQBGAFUAYQBRAFMASgB4ADUAeQBRADgAagBaAEUAZQAyAHgANABCADMAMQB2AEIAMgBqAC8AUgBLAGEAWQAvAHEAeQB0AHoANwBUAHYAdAB3AHQAagBzADYAUQBYAEIAZQA4AHMAZwBJAG8AOQBiADUAQQBCADcAOAAxAHMANgAvAGQAUwBFAHgATgBEAEQAYQBRAHoAQQBYAFAAWABCAFkAdQBYAFEARQBzAE8AegA4AHQAcgBpAGUATQBiAEIAZQBUAFkAOQBiAG8AQgBOAE8AaQBVADcATgBSAEYAOQAzAG8AVgArAFYAQQBiAGgAcAAwAHAAUgBQAFMAZQBmAEcARwBPAHEAdwBTAGcANwA3AHMAaAA5AEoASABNAHAARABNAFMAbgBrAHEAcgAyAGYARgBpAEMAUABrAHcAVgBvAHgANgBuAG4AeABGAEQAbwBXAC8AYQAxAHQAYQBaAHcAegB5AGwATAAxADIAdwB1AGIAbQA1AHUAbQBwAHEAeQBXAGMASwBSAGoAeQBoADIASgBUAEYASgBXADUAZwBYAEUASQA1AHAAOAAwAEcAdQAyAG4AeABMAFIATgB3AGkAdwByADcAVwBNAFIAQQBWAEsARgBXAE0AZQBSAHoAbAA5AFUAcQBnAC8AcABYAC8AdgBlAEwAdwBTAGsAMgBTAFMASABmAGEASwA2AGoAYQBvAFkAdQBuAFIARwByADgAbQBiAEUAbwBIAGwARgA2AEoAQwBhAGEAVABCAFgAQgBjAHYAdQBlAEMASgBvADkAOABoAFIAQQByAEcAdwA0ACsAUABIAGUAVABiAE4AUwBFAFgAWAB6AHYAWgA2AHUAVwA1AEUAQQBmAGQAWgBtAFMAOAA4AFYASgBjAFoAYQBGAEsANwB4AHgAZwAwAHcAbwBuADcAaAAwAHgAQwA2AFoAQgAwAGMAWQBqAEwAcgAvAEcAZQBPAHoAOQBHADQAUQBVAEgAOQBFAGsAeQAwAGQAeQBGAC8AcgBlAFUAMQBJAHkAaQBhAHAAcABoAE8AUAA4AFMAMgB0ADQAQgByAFAAWgBYAFQAdgBDADAA\
UAA3AHoATwArAGYARwBrAHgAVgBtACsAVQBmAFoAYgBRADUANQBzAHcARQA9ACYAcAA9AA==</Device>";
// ---------------------------------------------------------------------------
// FE3 handler (all associated functions – no instance state required)
// ---------------------------------------------------------------------------
pub struct FE3Handler;
impl FE3Handler {
// -----------------------------------------------------------------------
// Cookie
// -----------------------------------------------------------------------
/// POST the GetCookie SOAP envelope to FE3 and return the `EncryptedData`
/// value extracted from the response XML.
pub async fn get_cookie(client: &reqwest::Client) -> Result<String, StoreError> {
debug!("FE3: POST {FE3_DELIVERY} (GetCookie)");
let response = client
.post(FE3_DELIVERY)
.header("Content-Type", "application/soap+xml; charset=utf-8")
.body(GET_COOKIE_XML)
.send()
.await
.map_err(StoreError::Http)?;
let status = response.status();
debug!("FE3 GetCookie response: HTTP {status}");
let body = response.text().await.map_err(StoreError::Http)?;
trace!("FE3 GetCookie body:\n{body}");
let doc = roxmltree::Document::parse(&body).map_err(|e| StoreError::Xml(e.to_string()))?;
let cookie = doc
.descendants()
.find(|n| n.tag_name().name() == "EncryptedData")
.and_then(|n| n.text())
.ok_or_else(|| {
StoreError::Xml("EncryptedData node not found in cookie response".into())
})?;
debug!("FE3: cookie obtained ({} bytes)", cookie.len());
Ok(cookie.to_owned())
}
// -----------------------------------------------------------------------
// SyncUpdates
// -----------------------------------------------------------------------
/// POST a `SyncUpdates` request using a pre-obtained `cookie`. Returns the
/// HTML-decoded SOAP response body.
///
/// Most callers should use [`Self::sync_updates`], which fetches a cookie
/// internally. This variant is for callers that need to emit a progress
/// event between the two HTTP requests.
pub async fn sync_updates_with_cookie(
cookie: &str,
wu_category_id: &str,
msa_token: Option<&str>,
client: &reqwest::Client,
) -> Result<String, StoreError> {
let token = msa_token.unwrap_or(MSA_TOKEN);
let body = WUID_REQUEST_XML
.replace("{0}", cookie)
.replace("{1}", wu_category_id)
.replace("{2}", token);
debug!("FE3: POST {FE3_DELIVERY} (SyncUpdates, WuCategoryId={wu_category_id})");
let response = client
.post(FE3_DELIVERY)
.header("Content-Type", "application/soap+xml; charset=utf-8")
.body(body)
.send()
.await
.map_err(StoreError::Http)?;
let status = response.status();
debug!("FE3 SyncUpdates response: HTTP {status}");
let raw = response.text().await.map_err(StoreError::Http)?;
let decoded = html_decode(&raw);
trace!("FE3 SyncUpdates body:\n{decoded}");
Ok(decoded)
}
/// Fetch a FE3 cookie, then POST a `SyncUpdates` request for the given
/// `wu_category_id`. Returns the HTML-decoded SOAP response body.
pub async fn sync_updates(
wu_category_id: &str,
msa_token: Option<&str>,
client: &reqwest::Client,
) -> Result<String, StoreError> {
let cookie = Self::get_cookie(client).await?;
Self::sync_updates_with_cookie(&cookie, wu_category_id, msa_token, client).await
}
// -----------------------------------------------------------------------
// Process update IDs
// -----------------------------------------------------------------------
/// Parse the raw `SyncUpdates` XML and extract `(update_ids, revision_ids)`.
///
/// Only nodes whose XML fragment contains a `SecuredFragment` child are
/// included, matching the logic from the original C# code.
pub fn process_update_ids(xml: &str) -> Result<(Vec<String>, Vec<String>), StoreError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| StoreError::Xml(e.to_string()))?;
let mut update_ids = Vec::new();
let mut revision_ids = Vec::new();
for node in doc.descendants() {
if node.tag_name().name() != "SecuredFragment" {
continue;
}
// SecuredFragment -> parent (Properties) -> parent (Xml/Update element)
// -> first_child (UpdateIdentity)
let identity = node
.parent()
.and_then(|p| p.parent())
.and_then(|gp| gp.first_element_child());
if let Some(identity) = identity {
if let (Some(uid), Some(rev)) = (
identity.attribute("UpdateID"),
identity.attribute("RevisionNumber"),
) {
debug!("FE3: update ID={uid} revision={rev}");
update_ids.push(uid.to_owned());
revision_ids.push(rev.to_owned());
}
} else {
warn!("FE3: SecuredFragment node has unexpected parent structure; skipping");
}
}
debug!("FE3: process_update_ids found {} ID(s)", update_ids.len());
Ok((update_ids, revision_ids))
}
// -----------------------------------------------------------------------
// Package instances
// -----------------------------------------------------------------------
/// Parse `AppxMetadata` nodes from the `SyncUpdates` XML and build
/// [`PackageInstance`] values (without resolved download URLs).
///
/// The sibling `<File InstallerSpecificIdentifier="..." FileName="..."/>`
/// element carries the canonical filename (e.g. `<guid>.appxbundle`); we
/// match by `InstallerSpecificIdentifier == PackageMoniker` and stash
/// the value on [`PackageInstance::file_name`] so callers can derive
/// the correct download extension without guessing.
pub async fn get_package_instances(xml: &str) -> Result<Vec<PackageInstance>, StoreError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| StoreError::Xml(e.to_string()))?;
// First pass: build moniker → filename lookup from every <File> node.
let mut filename_by_moniker: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for node in doc.descendants() {
if node.tag_name().name() != "File" {
continue;
}
if let (Some(moniker), Some(filename)) = (
node.attribute("InstallerSpecificIdentifier"),
node.attribute("FileName"),
) {
// Skip blockmap files — they share the moniker but use the
// primary file's `FileName` should always win. The first
// <File> for a given moniker is the binary; subsequent ones
// (blockmap, etc.) we ignore via `entry().or_insert`.
filename_by_moniker
.entry(moniker.to_owned())
.or_insert_with(|| filename.to_owned());
}
}
let mut instances = Vec::new();
for node in doc.descendants() {
if node.tag_name().name() != "AppxMetadata" {
continue;
}
// Must have at least 3 attributes (PackageMoniker, PackageType, ...)
let attrs: Vec<_> = node.attributes().collect();
if attrs.len() < 3 {
continue;
}
let moniker = match node.attribute("PackageMoniker") {
Some(v) => v.to_owned(),
None => continue,
};
let pkg_type_str = node.attribute("PackageType").unwrap_or("");
let pkg_type = string_to_package_type(pkg_type_str);
debug!("FE3: package instance moniker={moniker} type={pkg_type_str}");
// First child text node carries the ApplicabilityBlob JSON.
let blob: Option<ApplicabilityBlob> =
node.first_child().and_then(|c| c.text()).and_then(|t| {
trace!("FE3: ApplicabilityBlob JSON: {t}");
serde_json::from_str(t).ok()
});
let file_name = filename_by_moniker.get(&moniker).cloned();
let readable_file_name =
PackageInstance::build_readable_file_name(&moniker, file_name.as_deref());
instances.push(PackageInstance {
package_moniker: moniker,
package_uri: None,
package_type: pkg_type,
applicability_blob: blob,
update_id: String::new(),
file_size: None,
file_name,
readable_file_name,
});
}
Ok(instances)
}
// -----------------------------------------------------------------------
// File URLs
// -----------------------------------------------------------------------
/// For each `(update_id, revision_id)` pair, POST a
/// `GetExtendedUpdateInfo2` SOAP request to FE3 and collect the resulting
/// file URLs and sizes (blockmap entries – always length 99 – are filtered out).
pub async fn get_file_urls(
update_ids: &[String],
revision_ids: &[String],
msa_token: Option<&str>,
client: &reqwest::Client,
) -> Result<Vec<(String, Option<i64>)>, StoreError> {
Self::get_file_urls_with_progress(
update_ids,
revision_ids,
msa_token,
client,
|_idx, _total, _update_id, _url, _size| {},
)
.await
}
/// Variant of [`Self::get_file_urls`] that fires `on_url` once per
/// successfully-resolved (non-blockmap) URL **as soon as the SOAP
/// response is parsed**, before the next request goes out. Use this to
/// stream live per-link updates to a UI.
///
/// Callback args: `(request_idx, request_total, update_id, url, size)`.
/// `request_idx` is 0-based over `update_ids`; `request_total` equals
/// `update_ids.len()`. The callback may fire multiple times per request
/// if FE3 returns more than one `<FileLocation>` (rare for store
/// packages — almost always 1 per update_id).
pub async fn get_file_urls_with_progress<F>(
update_ids: &[String],
revision_ids: &[String],
msa_token: Option<&str>,
client: &reqwest::Client,
on_url: F,
) -> Result<Vec<(String, Option<i64>)>, StoreError>
where
F: Fn(usize, usize, &str, &str, Option<i64>),
{
let token = msa_token.unwrap_or(MSA_TOKEN);
let total = update_ids.len();
let mut results = Vec::new();
for (i, update_id) in update_ids.iter().enumerate() {
let revision_id = match revision_ids.get(i) {
Some(r) => r.as_str(),
None => continue,
};
let body = FE3_FILE_URL_XML
.replace("{0}", update_id)
.replace("{1}", revision_id)
.replace("{2}", token);
debug!("FE3: POST {FE3_DELIVERY_SECURED} (GetExtendedUpdateInfo2, UpdateID={update_id} RevisionID={revision_id})");
let response = client
.post(FE3_DELIVERY_SECURED)
.header("Content-Type", "application/soap+xml; charset=utf-8")
.body(body)
.send()
.await
.map_err(StoreError::Http)?;
let status = response.status();
debug!("FE3 GetExtendedUpdateInfo2 response: HTTP {status}");
let raw = response.text().await.map_err(StoreError::Http)?;
debug!("FE3 GetExtendedUpdateInfo2 body:\n{raw}");
let doc = match roxmltree::Document::parse(&raw) {
Ok(d) => d,
Err(e) => return Err(StoreError::Xml(e.to_string())),
};
for file_loc in doc.descendants() {
if file_loc.tag_name().name() != "FileLocation" {
continue;
}
let mut url_opt: Option<String> = None;
let mut size_opt: Option<i64> = None;
for child in file_loc.children() {
match child.tag_name().name() {
"Url" => {
if let Some(text) = child.text() {
if text.len() != 99 {
debug!("FE3: URL resolved: {text}");
url_opt = Some(text.to_owned());
} else {
trace!("FE3: skipping blockmap URL (len=99)");
}
}
}
"FileSize" => {
size_opt = child.text().and_then(|t| t.parse::<i64>().ok());
debug!("FE3: FileSize={size_opt:?}");
}
_ => {}
}
}
if let Some(url) = url_opt {
on_url(i, total, update_id, &url, size_opt);
results.push((url, size_opt));
}
}
}
Ok(results)
}
}
// ---------------------------------------------------------------------------
// HTML entity decoder
// ---------------------------------------------------------------------------
/// Minimal HTML entity decoder covering the entities used in SOAP responses.
pub(crate) fn html_decode(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_decode_all_entities() {
assert_eq!(html_decode("a & b"), "a & b");
assert_eq!(html_decode("<tag>"), "<tag>");
assert_eq!(html_decode("say "hello""), "say \"hello\"");
assert_eq!(html_decode("it's"), "it's");
}
#[test]
fn html_decode_no_entities() {
assert_eq!(html_decode("plain text"), "plain text");
}
#[test]
fn html_decode_chained_entities() {
// Replacements are sequential: &lt; -> < -> <
assert_eq!(html_decode("&lt;"), "<");
}
#[test]
fn process_update_ids_empty_xml() {
let xml = r#"<?xml version="1.0"?><root></root>"#;
let (ids, revs) = FE3Handler::process_update_ids(xml).unwrap();
assert!(ids.is_empty());
assert!(revs.is_empty());
}
#[test]
fn process_update_ids_parses_secured_fragment() {
// Minimal SyncUpdates-style XML with one SecuredFragment node.
let xml = r#"<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<SyncUpdatesResult>
<NewUpdates>
<UpdateInfo>
<ID>1</ID>
<Xml>
<UpdateIdentity UpdateID="abc-123" RevisionNumber="200"/>
<Properties>
<SecuredFragment/>
</Properties>
</Xml>
</UpdateInfo>
</NewUpdates>
</SyncUpdatesResult>
</s:Body>
</s:Envelope>"#;
let (ids, revs) = FE3Handler::process_update_ids(xml).unwrap();
assert_eq!(ids, vec!["abc-123"]);
assert_eq!(revs, vec!["200"]);
}
#[test]
fn process_update_ids_skips_nodes_without_secured_fragment() {
let xml = r#"<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<SyncUpdatesResult>
<NewUpdates>
<UpdateInfo>
<ID>1</ID>
<Xml>
<UpdateIdentity UpdateID="no-fragment" RevisionNumber="1"/>
<Properties/>
</Xml>
</UpdateInfo>
</NewUpdates>
</SyncUpdatesResult>
</s:Body>
</s:Envelope>"#;
let (ids, _) = FE3Handler::process_update_ids(xml).unwrap();
assert!(ids.is_empty());
}
#[test]
fn process_update_ids_multiple() {
let xml = r#"<?xml version="1.0"?>
<root>
<Update>
<UpdateIdentity UpdateID="id-1" RevisionNumber="10"/>
<Properties>
<SecuredFragment/>
</Properties>
</Update>
<Update>
<UpdateIdentity UpdateID="id-2" RevisionNumber="20"/>
<Properties>
<SecuredFragment/>
</Properties>
</Update>
</root>"#;
let (ids, revs) = FE3Handler::process_update_ids(xml).unwrap();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], "id-1");
assert_eq!(revs[0], "10");
assert_eq!(ids[1], "id-2");
assert_eq!(revs[1], "20");
}
#[test]
fn process_update_ids_invalid_xml_returns_error() {
let result = FE3Handler::process_update_ids("not xml at all <<<");
assert!(result.is_err());
}
}