diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index fd1fd0e..180faae 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -366,11 +366,33 @@ async fn cdp_processor( let mut deferred: std::collections::VecDeque = std::collections::VecDeque::new(); - // Subscribe to Ctrl-C once. The future is single-shot, so we break out of - // the outer loop when it fires and never poll it again. Without this the - // accept loop just exits and any cookies set during the session are lost - // before `BrowserContext::save_cookies()` runs. - let mut shutdown = Box::pin(tokio::signal::ctrl_c()); + // Subscribe to graceful-shutdown signals once. The future is single-shot, + // so we break out of the outer loop when it fires and never poll it again. + // Without this the accept loop just exits and any cookies set during the + // session are lost before `BrowserContext::save_cookies()` runs. We watch + // SIGTERM as well as Ctrl-C (SIGINT) so `docker stop` / `kill`, which send + // SIGTERM, also flush cookies to the storage dir (issue #333). + let mut shutdown = Box::pin(async { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + match signal(SignalKind::terminate()) { + Ok(mut term) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } + }); loop { // Drain any deferred messages from the previous interception window diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 8ede02f..4c5acf4 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -167,6 +167,15 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser { type Impl = ObscuraSelector; type Error = SelectorParseErrorKind<'i>; + // Allow `:has()`. The selectors crate gates relative-selector parsing on + // this (default false), so without it `a:has(p)` failed to parse and the + // error was swallowed into an empty match set. Matching already passes a + // SelectorCaches (which holds the relative-selector cache), so enabling + // parsing is sufficient. + fn parse_has(&self) -> bool { + true + } + fn parse_non_ts_pseudo_class( &self, _location: cssparser::SourceLocation, @@ -219,7 +228,18 @@ impl<'a> Element for DomElement<'a> { type Impl = ObscuraSelector; fn opaque(&self) -> OpaqueElement { - OpaqueElement::new(self) + // Must be stable per node. DomElement is Copy and gets a fresh stack + // address on every traversal step, so OpaqueElement::new(self) returns a + // different id for the same node each call. That breaks `:has` anchor + // matching, which compares the anchor's opaque against the element + // reached by walking up the tree. Key off the node's stable slot in the + // tree's Vec instead (OpaqueElement only compares the address, never + // dereferences it, and the Vec is not mutated during a query). + let inner = self.tree.borrow_inner(); + match inner.nodes.get(self.node_id.index()) { + Some(slot) => OpaqueElement::new(slot), + None => OpaqueElement::new(self), + } } fn parent_element(&self) -> Option { @@ -666,6 +686,21 @@ mod tests { assert_eq!(results.len(), 3); } + #[test] + fn test_has_parses() { + // Isolate parse from match: :has must parse, not error. + assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse"); + } + + #[test] + fn test_query_selector_has() { + let tree = parse_html(r#"

x

"#); + let all = tree.query_selector_all("a:has(p.bt)").unwrap(); + assert_eq!(all.len(), 1, "a:has(p.bt) should match the "); + let none = tree.query_selector_all("a:has(span.bt)").unwrap(); + assert_eq!(none.len(), 0, "a:has(span.bt) should match nothing"); + } + #[test] fn test_query_selector_descendant() { let tree = diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index ff3350e..61a3934 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -959,7 +959,15 @@ class Element extends Node { } get id() { return this.getAttribute("id") || ""; } set id(v) { this.setAttribute("id", v); } - get className() { return this.getAttribute("class") || ""; } + get className() { + // SVG elements reflect class as an SVGAnimatedString (.baseVal/.animVal), + // not a plain string. Anti-fraud sensors read el.className.animVal. + if (this.namespaceURI === "http://www.w3.org/2000/svg") { + if (!this._svgClassName) this._svgClassName = new SVGAnimatedString(this, "class"); + return this._svgClassName; + } + return this.getAttribute("class") || ""; + } set className(v) { this.setAttribute("class", v); } get namespaceURI() { // createElementNS records the requested namespace on _ns; an empty string @@ -1581,6 +1589,16 @@ class Element extends Node { // members use. Other elements reflect the raw attribute. get href() { const ln = this.localName; + // SVG href-bearing elements reflect href as an SVGAnimatedString (with the + // legacy xlink:href as a fallback), not a resolved URL string. Checked + // before the HTML path because an SVG also has localName 'a'. + if (this.namespaceURI === "http://www.w3.org/2000/svg" && + (ln === 'a' || ln === 'image' || ln === 'use' || ln === 'script' || + ln === 'pattern' || ln === 'filter' || ln === 'textPath' || ln === 'mpath' || + ln === 'linearGradient' || ln === 'radialGradient' || ln === 'feImage' || ln === 'tref')) { + if (!this._svgHref) this._svgHref = new SVGAnimatedString(this, "href", "xlink:href"); + return this._svgHref; + } if (ln === 'a' || ln === 'area') { const raw = this.getAttribute('href'); if (raw === null) return ''; @@ -4997,6 +5015,31 @@ globalThis.HTMLLegendElement = Element; globalThis.HTMLProgressElement = Element; globalThis.HTMLDetailsElement = Element; globalThis.HTMLDialogElement = Element; +// SVGAnimatedString backs the className and href reflections on SVG elements. +// baseVal and animVal both read the live attribute (no SMIL animation), and +// baseVal is writable. Used by the SVG-aware get className()/get href() above. +function SVGAnimatedString(el, attr, fallbackAttr) { + this._el = el; + this._attr = attr; + this._fallback = fallbackAttr || null; +} +SVGAnimatedString.prototype._read = function() { + let v = this._el.getAttribute(this._attr); + if (v === null && this._fallback) v = this._el.getAttribute(this._fallback); + return v == null ? '' : v; +}; +Object.defineProperty(SVGAnimatedString.prototype, 'baseVal', { + get() { return this._read(); }, + set(v) { this._el.setAttribute(this._attr, String(v)); }, + configurable: true, enumerable: true, +}); +Object.defineProperty(SVGAnimatedString.prototype, 'animVal', { + get() { return this._read(); }, + configurable: true, enumerable: true, +}); +Object.defineProperty(SVGAnimatedString.prototype, Symbol.toStringTag, { value: 'SVGAnimatedString', configurable: true }); +_markNative(SVGAnimatedString); + globalThis.SVGElement = Element; globalThis.SVGSVGElement = Element; globalThis.CharacterData = CharacterData;