Skip to content

Commit a280a45

Browse files
authored
Merge pull request #20 from NativeScript/feat/metadata-filter
Metadata filtering from native-api-usage.json
2 parents f7cf5ac + e359960 commit a280a45

14 files changed

Lines changed: 613 additions & 45 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
22
resolver = "2"
3-
members = ["metadata", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi", "napi-v8-shim"]
3+
members = ["metadata", "metadata-filter", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi", "napi-v8-shim"]
44
# Excluded so their C builds / prebuilt-engine links don't run on normal `cargo` invocations.
55
exclude = ["packages/common", "packages/demo", "packages/windows-quickjs", "packages/windows-hermes", "packages/windows-jsc", "packages/windows-v8"]
66

‎metadata-filter/Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[package]
2+
name = "metadata-filter"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]

‎metadata-filter/src/lib.rs‎

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
use std::fmt;
2+
use std::fs;
3+
use std::io;
4+
use std::path::Path;
5+
6+
pub const WHITELIST_FILE: &str = "whitelist.mdg";
7+
pub const BLACKLIST_FILE: &str = "blacklist.mdg";
8+
9+
/// Always allowed, as Android always allows `com.tns.gen*`: the runtime's own types and sbg's
10+
/// generated proxies (`NativeScript.Gen.*`).
11+
const ALWAYS_ALLOWED_ROOT: &str = "NativeScript";
12+
13+
/// Roots a JS extension can derive from without WinRT metadata to check it against (the Windows
14+
/// App SDK's `Microsoft.UI.*` lives in a framework package, not system metadata).
15+
const EXTENDABLE_ROOTS: &[&str] = &["Windows", "Microsoft", "System", "NativeScript"];
16+
17+
#[derive(Debug, Clone, PartialEq, Eq)]
18+
pub struct Pattern {
19+
namespace: String,
20+
type_name: String,
21+
}
22+
23+
impl Pattern {
24+
fn matches(&self, namespace: &str, type_name: &str) -> bool {
25+
(self.namespace.is_empty() || wildcard_match(&self.namespace, namespace))
26+
&& (self.type_name.is_empty() || wildcard_match(&self.type_name, type_name))
27+
}
28+
}
29+
30+
impl fmt::Display for Pattern {
31+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32+
if self.type_name.is_empty() {
33+
f.write_str(&self.namespace)
34+
} else {
35+
write!(f, "{}:{}", self.namespace, self.type_name)
36+
}
37+
}
38+
}
39+
40+
#[derive(Debug, Clone, Default)]
41+
pub struct PatternList(Vec<Pattern>);
42+
43+
impl PatternList {
44+
pub fn parse(text: &str) -> Self {
45+
let patterns = text
46+
.lines()
47+
.map(str::trim)
48+
.filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("//"))
49+
.map(|line| {
50+
let (namespace, type_name) = line.split_once(':').unwrap_or((line, ""));
51+
Pattern {
52+
namespace: namespace.trim().to_owned(),
53+
type_name: type_name.trim().to_owned(),
54+
}
55+
})
56+
.collect();
57+
Self(patterns)
58+
}
59+
60+
/// `None` when there is no such file.
61+
pub fn from_file(path: &Path) -> io::Result<Option<Self>> {
62+
match fs::read_to_string(path) {
63+
Ok(text) => Ok(Some(Self::parse(&text))),
64+
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
65+
Err(error) => Err(error),
66+
}
67+
}
68+
69+
pub fn is_empty(&self) -> bool {
70+
self.0.is_empty()
71+
}
72+
73+
fn find(&self, namespace: &str, type_name: &str) -> Option<&Pattern> {
74+
self.0.iter().find(|pattern| pattern.matches(namespace, type_name))
75+
}
76+
}
77+
78+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79+
pub enum Verdict<'a> {
80+
Allowed,
81+
NotWhitelisted,
82+
Blacklisted(&'a Pattern),
83+
}
84+
85+
impl fmt::Display for Verdict<'_> {
86+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87+
match self {
88+
Verdict::Allowed => f.write_str("allowed"),
89+
Verdict::NotWhitelisted => f.write_str("not whitelisted"),
90+
Verdict::Blacklisted(pattern) => write!(f, "blacklisted by '{pattern}'"),
91+
}
92+
}
93+
}
94+
95+
/// The native API an app allows itself (`App_Resources/Windows/native-api-usage.json`, which the CLI
96+
/// writes out as `whitelist.mdg` / `blacklist.mdg`, as for Android and iOS). Each line is a
97+
/// `namespace:type` pattern (`*` and `?` wildcards; a bare `namespace` covers every type in it; `#`
98+
/// and `//` start comments). A whitelist, when there is one, is exclusive; the blacklist wins.
99+
#[derive(Debug, Clone, Default)]
100+
pub struct MetadataFilter {
101+
whitelist: Option<PatternList>,
102+
blacklist: PatternList,
103+
}
104+
105+
impl MetadataFilter {
106+
pub fn new(whitelist: Option<PatternList>, blacklist: PatternList) -> Self {
107+
Self { whitelist, blacklist }
108+
}
109+
110+
/// `whitelist.mdg` and `blacklist.mdg` in `dir`, where present.
111+
pub fn load(dir: &Path) -> io::Result<Self> {
112+
Self::from_files(Some(&dir.join(WHITELIST_FILE)), Some(&dir.join(BLACKLIST_FILE)))
113+
}
114+
115+
pub fn from_files(whitelist: Option<&Path>, blacklist: Option<&Path>) -> io::Result<Self> {
116+
let whitelist = match whitelist {
117+
Some(path) => PatternList::from_file(path)?,
118+
None => None,
119+
};
120+
let blacklist = match blacklist {
121+
Some(path) => PatternList::from_file(path)?.unwrap_or_default(),
122+
None => PatternList::default(),
123+
};
124+
Ok(Self::new(whitelist, blacklist))
125+
}
126+
127+
/// Nothing is filtered.
128+
pub fn is_empty(&self) -> bool {
129+
self.whitelist.is_none() && self.blacklist.is_empty()
130+
}
131+
132+
/// `full_name`: a type's full name, generic (`` Windows.Foundation.IReference`1 ``) or not.
133+
pub fn check(&self, full_name: &str) -> Verdict<'_> {
134+
if self.is_empty() {
135+
return Verdict::Allowed;
136+
}
137+
let (namespace, type_name) = split_type_name(full_name);
138+
if let Some(pattern) = self.blacklist.find(namespace, type_name) {
139+
return Verdict::Blacklisted(pattern);
140+
}
141+
let whitelisted = match &self.whitelist {
142+
None => true,
143+
Some(whitelist) => is_always_allowed(namespace) || whitelist.find(namespace, type_name).is_some(),
144+
};
145+
if whitelisted {
146+
Verdict::Allowed
147+
} else {
148+
Verdict::NotWhitelisted
149+
}
150+
}
151+
152+
pub fn allows(&self, full_name: &str) -> bool {
153+
self.check(full_name) == Verdict::Allowed
154+
}
155+
}
156+
157+
fn is_always_allowed(namespace: &str) -> bool {
158+
namespace == ALWAYS_ALLOWED_ROOT
159+
|| namespace
160+
.strip_prefix(ALWAYS_ALLOWED_ROOT)
161+
.is_some_and(|rest| rest.starts_with('.'))
162+
}
163+
164+
/// Namespace and type name, without generic arity or arguments.
165+
fn split_type_name(full_name: &str) -> (&str, &str) {
166+
let open = full_name.split('<').next().unwrap_or(full_name);
167+
let open = open.split('`').next().unwrap_or(open);
168+
open.rsplit_once('.').unwrap_or(("", open))
169+
}
170+
171+
/// `*` matches any run of characters (none included), `?` any one.
172+
pub fn wildcard_match(pattern: &str, input: &str) -> bool {
173+
let pattern: Vec<char> = pattern.chars().collect();
174+
let input: Vec<char> = input.chars().collect();
175+
let (mut p, mut i) = (0, 0);
176+
// The last `*` seen and where in `input` it started matching, to backtrack to.
177+
let mut star: Option<(usize, usize)> = None;
178+
while i < input.len() {
179+
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == input[i]) {
180+
p += 1;
181+
i += 1;
182+
} else if p < pattern.len() && pattern[p] == '*' {
183+
star = Some((p, i));
184+
p += 1;
185+
} else if let Some((star_p, star_i)) = star {
186+
p = star_p + 1;
187+
i = star_i + 1;
188+
star = Some((star_p, star_i + 1));
189+
} else {
190+
return false;
191+
}
192+
}
193+
pattern[p..].iter().all(|&c| c == '*')
194+
}
195+
196+
fn is_identifier(segment: &str) -> bool {
197+
let mut chars = segment.chars();
198+
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
199+
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
200+
}
201+
202+
/// A name that could be a WinRT/.NET type: two or more identifier segments (a generic arity
203+
/// suffix aside). A bundled library's minified class (`Ua.$`) is not.
204+
pub fn is_dotted_identifier(name: &str) -> bool {
205+
let open = name.split('`').next().unwrap_or(name);
206+
let mut segments = open.split('.');
207+
let first_two = segments.next().is_some_and(is_identifier) && segments.next().is_some_and(is_identifier);
208+
first_two && segments.all(is_identifier)
209+
}
210+
211+
/// Whether a JS class's base can be a WinRT type without metadata to look it up in: a dotted
212+
/// identifier under a root JS extends native types from. Library classes (`u.MaterialDefines`,
213+
/// `Phaser.Utils`) are not; sbg also accepts bases it finds in WinRT metadata.
214+
pub fn plausible_base(name: &str) -> bool {
215+
is_dotted_identifier(name) && name.split('.').next().is_some_and(|root| EXTENDABLE_ROOTS.contains(&root))
216+
}
217+
218+
#[cfg(test)]
219+
mod tests {
220+
use super::*;
221+
222+
fn filter(whitelist: Option<&str>, blacklist: &str) -> MetadataFilter {
223+
MetadataFilter::new(whitelist.map(PatternList::parse), PatternList::parse(blacklist))
224+
}
225+
226+
#[test]
227+
fn parses_lines_like_android_and_ios() {
228+
let list = PatternList::parse("# comment\n// comment\n\n Windows.Storage:StorageFile \nWindows.UI.*\n");
229+
assert_eq!(list.0.len(), 2);
230+
assert_eq!(list.0[0].to_string(), "Windows.Storage:StorageFile");
231+
assert_eq!(list.0[1].to_string(), "Windows.UI.*");
232+
}
233+
234+
#[test]
235+
fn wildcards() {
236+
assert!(wildcard_match("*", ""));
237+
assert!(wildcard_match("*", "Windows"));
238+
assert!(wildcard_match("Windows.*", "Windows.Storage"));
239+
assert!(!wildcard_match("Windows.*", "Windows"));
240+
assert!(wildcard_match("Windows*", "Windows"));
241+
assert!(wildcard_match("W?ndows", "Windows"));
242+
assert!(!wildcard_match("W?ndows", "Wndows"));
243+
assert!(wildcard_match("*.Storage.*", "Windows.Storage.Pickers"));
244+
assert!(wildcard_match("a*b*c", "axxbyyc"));
245+
assert!(!wildcard_match("a*b*c", "axxbyy"));
246+
assert!(!wildcard_match("Storage", "StorageFile"));
247+
// A long mismatch stays linear-ish instead of blowing up.
248+
assert!(!wildcard_match("*a*a*a*a*a*a*a*b", &"a".repeat(200)));
249+
}
250+
251+
#[test]
252+
fn no_files_allow_everything() {
253+
let f = MetadataFilter::default();
254+
assert!(f.is_empty());
255+
assert!(f.allows("Windows.Storage.StorageFile"));
256+
}
257+
258+
#[test]
259+
fn a_whitelist_is_exclusive() {
260+
let f = filter(Some("Windows.Storage:StorageFile\nMicrosoft.UI.Xaml*"), "");
261+
assert!(f.allows("Windows.Storage.StorageFile"));
262+
assert!(f.allows("Microsoft.UI.Xaml.Controls.Button"));
263+
assert_eq!(f.check("Windows.Storage.StorageFolder"), Verdict::NotWhitelisted);
264+
}
265+
266+
#[test]
267+
fn the_blacklist_wins() {
268+
let f = filter(Some("Windows.Storage*"), "Windows.Storage.Pickers");
269+
assert!(f.allows("Windows.Storage.StorageFile"));
270+
let verdict = f.check("Windows.Storage.Pickers.FileOpenPicker");
271+
assert_eq!(verdict.to_string(), "blacklisted by 'Windows.Storage.Pickers'");
272+
}
273+
274+
#[test]
275+
fn generic_names_match_their_open_type() {
276+
let f = filter(None, "Windows.Foundation:IReference");
277+
assert!(!f.allows("Windows.Foundation.IReference`1"));
278+
assert!(!f.allows("Windows.Foundation.IReference`1<Int32>"));
279+
assert!(f.allows("Windows.Foundation.Uri"));
280+
}
281+
282+
#[test]
283+
fn the_runtimes_own_types_pass_a_whitelist_but_not_the_blacklist() {
284+
let f = filter(Some("Windows.Storage*"), "");
285+
assert!(f.allows("NativeScript.Gen.Button_1"));
286+
assert!(f.allows("NativeScript.Widgets.StackLayout"));
287+
assert!(!f.allows("NativeScriptish.Thing"));
288+
let f = filter(Some("Windows.Storage*"), "NativeScript.Widgets");
289+
assert!(!f.allows("NativeScript.Widgets.StackLayout"));
290+
}
291+
292+
#[test]
293+
fn plausible_bases() {
294+
assert!(plausible_base("Microsoft.UI.Xaml.Controls.Button"));
295+
assert!(plausible_base("Windows.Foundation.Collections.IVector`1"));
296+
assert!(!plausible_base("Ua.$"));
297+
assert!(!plausible_base("u.MaterialDefines"));
298+
assert!(!plausible_base("Phaser.Utils"));
299+
assert!(!plausible_base("Windows"));
300+
assert!(is_dotted_identifier("CommunityToolkit.WinUI.Controls.Segmented"));
301+
assert!(!is_dotted_identifier("Ua.$"));
302+
}
303+
}

‎metadata/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ahash = "0.8"
1212
parking_lot = "0.12"
1313
dyn-clone = "1.0"
1414
sha1 = "0.10"
15+
metadata-filter = { path = "../metadata-filter" }
1516

1617
[dependencies.windows]
1718
workspace = true

0 commit comments

Comments
 (0)